mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
105 Commits
v1.0.71
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d15abb547 | ||
|
|
1f565a290b | ||
|
|
68a77eee5c | ||
|
|
29a97dbde8 | ||
|
|
29a6a7b600 | ||
|
|
c167163d70 | ||
|
|
7988515e1c | ||
|
|
c7adff7a3b | ||
|
|
59237f3104 | ||
|
|
358cd06838 | ||
|
|
b0b1ca4b5d | ||
|
|
781d188a60 | ||
|
|
2e0fb9a880 | ||
|
|
927b37cd63 | ||
|
|
d2e22c5fca | ||
|
|
fdae560014 | ||
|
|
1b173e1953 | ||
|
|
57db1b3a8d | ||
|
|
4c1c5f5287 | ||
|
|
3d2c10cd0b | ||
|
|
03de81c5f3 | ||
|
|
7abcaa7f68 | ||
|
|
8fb2476985 | ||
|
|
56c9a2afd8 | ||
|
|
2029189809 | ||
|
|
ee427979a8 | ||
|
|
545abcbbde | ||
|
|
4a73e83f1e | ||
|
|
7496420fa8 | ||
|
|
43fabdf524 | ||
|
|
8c46c74105 | ||
|
|
70777c86c3 | ||
|
|
38e8806d91 | ||
|
|
a7865cd0a7 | ||
|
|
f77b7eea68 | ||
|
|
dd7f741b62 | ||
|
|
e7d5ecdd01 | ||
|
|
4807283368 | ||
|
|
d2bb36591f | ||
|
|
5a54bc07db | ||
|
|
a528b3cb69 | ||
|
|
f0176af330 | ||
|
|
715aa8d960 | ||
|
|
ebc0c53ab5 | ||
|
|
1e682bd97c | ||
|
|
70424c486c | ||
|
|
b8f56dbc0b | ||
|
|
c74d9b63fb | ||
|
|
67015eef8e | ||
|
|
af8507ea8e | ||
|
|
02c2ebcf7c | ||
|
|
abf6f99d7e | ||
|
|
8ba910eb9f | ||
|
|
78bf126bb0 | ||
|
|
4eefe32c1a | ||
|
|
8f6f8eb0fc | ||
|
|
80323bb464 | ||
|
|
0a33bd7c57 | ||
|
|
aafaed06a7 | ||
|
|
54ddcf490b | ||
|
|
bb246b591f | ||
|
|
fc2761d16b | ||
|
|
409a3172da | ||
|
|
483aadee3b | ||
|
|
e43f497650 | ||
|
|
990d633c07 | ||
|
|
d4168ab84f | ||
|
|
12ca42c953 | ||
|
|
d382ee9053 | ||
|
|
daaacb4977 | ||
|
|
680501c1df | ||
|
|
6675e3c247 | ||
|
|
7b48709438 | ||
|
|
c876841106 | ||
|
|
4c1a92caa6 | ||
|
|
577ff035c3 | ||
|
|
4b4ca4283a | ||
|
|
ad4a6d68c7 | ||
|
|
d8fb368ce4 | ||
|
|
40840915c7 | ||
|
|
fb57e17905 | ||
|
|
4cdfa2fcda | ||
|
|
3c2cc273f7 | ||
|
|
b52677269e | ||
|
|
78390f8ea1 | ||
|
|
d6cebd6723 | ||
|
|
79adf89beb | ||
|
|
9dd355a52d | ||
|
|
7b989948c4 | ||
|
|
6ff10229fd | ||
|
|
21cff2e2dd | ||
|
|
44514ad114 | ||
|
|
4a56748bfa | ||
|
|
0b6faa01bf | ||
|
|
1efe2dfb33 | ||
|
|
767386cb57 | ||
|
|
e71c76155e | ||
|
|
c363acf94e | ||
|
|
05285bb696 | ||
|
|
4c0f93bd6a | ||
|
|
76ebd49382 | ||
|
|
6c14c425fc | ||
|
|
27df16d3b2 | ||
|
|
47dc003601 | ||
|
|
4e0a6a988c |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,7 @@
|
||||
/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
|
||||
|
||||
124
.github/workflows/ci.yml
vendored
124
.github/workflows/ci.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: CI
|
||||
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -8,6 +9,12 @@ on:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
# PR metadata edits can retrigger full CI for the same head. Keep only the
|
||||
# newest run for a pull request; push and manual runs use a unique run ID.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -295,6 +302,11 @@ jobs:
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
mode: ${{ steps.e2e_domains.outputs.mode }}
|
||||
reason: ${{ steps.e2e_domains.outputs.reason }}
|
||||
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
@@ -308,6 +320,23 @@ jobs:
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Validate CLI E2E domain outputs
|
||||
env:
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
case "$E2E_MODE" in
|
||||
skip)
|
||||
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
|
||||
;;
|
||||
full|subset)
|
||||
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
|
||||
;;
|
||||
*)
|
||||
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
@@ -341,16 +370,22 @@ jobs:
|
||||
fi
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Live E2E uses one repository-wide execution slot.
|
||||
concurrency:
|
||||
group: lark-cli-e2e-live
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
@@ -361,31 +396,68 @@ jobs:
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
id: build_cli
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
- name: Prepare shared live E2E tenant token
|
||||
id: live_e2e_tat
|
||||
env:
|
||||
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
run: node scripts/fetch_e2e_tat.js
|
||||
- name: Run CLI E2E tests
|
||||
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
|
||||
# run is rejected below before it can start live E2E.
|
||||
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
|
||||
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
|
||||
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
|
||||
newer_runs="$(
|
||||
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
|
||||
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
|
||||
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
|
||||
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
|
||||
)"
|
||||
if [ -n "$newer_runs" ]; then
|
||||
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
|
||||
echo "::error::Missing shared live E2E tenant token file"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No live CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
|
||||
rm -f "$E2E_TENANT_AUTH_FILE"
|
||||
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
|
||||
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
|
||||
./lark-cli whoami --as bot | node -e '
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => { input += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
const result = JSON.parse(input);
|
||||
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
|
||||
});
|
||||
'; then
|
||||
echo "::error::Tenant credential preflight failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tenant credential preflight succeeded"
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
if [ -z "$packages" ]; then
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
@@ -395,7 +467,7 @@ jobs:
|
||||
echo "Live CLI E2E packages: $packages"
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
@@ -472,8 +544,8 @@ jobs:
|
||||
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
|
||||
# license-header on push) are OK.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live when not
|
||||
# needed or on a fork, license-header on push) are OK.
|
||||
#
|
||||
# plugin-integration and sidecar-integration are intentionally NOT
|
||||
# in this loop yet: they run on every PR and their status is shown
|
||||
|
||||
103
.github/workflows/release.yml
vendored
103
.github/workflows/release.yml
vendored
@@ -9,7 +9,40 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
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
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -26,35 +59,79 @@ 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: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Include release checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s dist/checksums.txt
|
||||
(cd dist && sha256sum --check checksums.txt)
|
||||
cp dist/checksums.txt checksums.txt
|
||||
|
||||
- name: Collect release asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir npm-publish-asset
|
||||
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
|
||||
|
||||
- name: Upload release asset
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset/
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
publish-npm:
|
||||
needs: goreleaser
|
||||
needs: build-release
|
||||
runs-on: ubuntu-22.04
|
||||
environment: npm-production
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- 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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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; }
|
||||
(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"
|
||||
|
||||
- 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,19 +25,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
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.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");
|
||||
@@ -253,19 +250,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
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.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");
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make test # Full: vet + unit + integration
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Notification Opt-Outs
|
||||
|
||||
203
CHANGELOG.md
203
CHANGELOG.md
@@ -2,6 +2,201 @@
|
||||
|
||||
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
|
||||
|
||||
- **slides**: add history rollback shortcuts (#1714)
|
||||
- **base**: support per-record batch updates (#1889)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- preserve slides schema issues
|
||||
- allow jq examples in quality gate dry-runs
|
||||
- **im**: warn when flag pagination is truncated (#1906)
|
||||
- **slides**: warn on text shape overflow
|
||||
- **slides**: exempt chart roundtrip attributes from lint
|
||||
- **slides**: detect image text occlusion
|
||||
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
|
||||
|
||||
### Documentation
|
||||
|
||||
- clarify drive upload overwrite guidance (#1982)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate unit tests from user state (#1883)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- converge success output through a single Emitter that owns the write (#1899)
|
||||
|
||||
## [v1.0.73] - 2026-07-20
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: detect visual elements outside canvas
|
||||
- reduce public content credential fixture false positives
|
||||
- standardize CLI shortcut text in English (#1942)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: reduce filter and update retry loops (#1879)
|
||||
- **vc**: default transcript routing to smart notes over minutes (#1961)
|
||||
- clarify local trigger automation (#1958)
|
||||
|
||||
### Tests
|
||||
|
||||
- synchronize temporary Git maintenance (#1946)
|
||||
|
||||
### Misc
|
||||
|
||||
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
|
||||
- [codex] support bot menu events (#1765)
|
||||
|
||||
## [v1.0.72] - 2026-07-17
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: lint table out of canvas
|
||||
- **slides**: report resolved table size mismatches
|
||||
- **approval**: support approval event consumption (#1924)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **vc**: don't fail +detail for in-progress meetings (#1930)
|
||||
- stabilize drive delete E2E terminal-state checks (#1939)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: document table dimensions
|
||||
- document base field default values (#1500)
|
||||
- **sheets**: use English placeholder in table-get guidance (#1936)
|
||||
|
||||
### Tests
|
||||
|
||||
- stabilize live e2e auth retries (#1904)
|
||||
- use tri-state wiki node identity in delete verification (#1931)
|
||||
- fix drive cover download retries (#1934)
|
||||
|
||||
## [v1.0.71] - 2026-07-16
|
||||
|
||||
### Features
|
||||
@@ -1527,6 +1722,14 @@ 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
|
||||
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
|
||||
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
|
||||
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
|
||||
|
||||
9
Makefile
9
Makefile
@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
|
||||
all: test
|
||||
|
||||
@@ -51,13 +51,18 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/e2e_domains.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/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
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
live-skills-test: fetch_meta
|
||||
LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \
|
||||
go test -v -count=1 ./cmd/update \
|
||||
-run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$'
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
# breaks, the plugin author guide's "go build ./..." path is broken.
|
||||
examples-build:
|
||||
|
||||
23
README.md
23
README.md
@@ -285,6 +285,29 @@ 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,6 +286,29 @@ 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
|
||||
|
||||
38
affordance/docs.md
Normal file
38
affordance/docs.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# docs
|
||||
> skill: lark-doc
|
||||
|
||||
## +create
|
||||
Create a document from XML or Markdown content.
|
||||
|
||||
### Skills
|
||||
- lark-doc/references/lark-doc-create.md
|
||||
|
||||
## +fetch
|
||||
Fetch a document or a focused portion of its content.
|
||||
|
||||
### Skills
|
||||
- lark-doc/references/lark-doc-fetch.md
|
||||
|
||||
## +update
|
||||
Update document content with a supported document command.
|
||||
|
||||
### Skills
|
||||
- lark-doc/references/lark-doc-update.md
|
||||
|
||||
## +history-list
|
||||
List document history versions.
|
||||
|
||||
### Skills
|
||||
- lark-doc/references/lark-doc-history.md
|
||||
|
||||
## +history-revert
|
||||
Revert a document to a history version.
|
||||
|
||||
### Skills
|
||||
- lark-doc/references/lark-doc-history.md
|
||||
|
||||
## +history-revert-status
|
||||
Check the status of a document history revert.
|
||||
|
||||
### Skills
|
||||
- lark-doc/references/lark-doc-history.md
|
||||
@@ -344,20 +344,18 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
// Streaming formats intentionally emit each page after that page has
|
||||
// passed safety scanning. A later page may still fail, so callers
|
||||
// must use the exit code to distinguish complete vs partial output.
|
||||
scanResult := output.ScanForSafety(commandPath, items, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
|
||||
396
cmd/api/api_paginate_test.go
Normal file
396
cmd/api/api_paginate_test.go
Normal file
@@ -0,0 +1,396 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type apiFailOnWriteWriter struct {
|
||||
buf bytes.Buffer
|
||||
writes int
|
||||
failAt int
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
|
||||
w.writes++
|
||||
if w.writes == w.failAt {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
previousNotice := output.PendingNotice
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
||||
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, out, errOut, reg := cmdutil.TestFactory(t, config)
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
|
||||
}
|
||||
ac.ErrOut = io.Discard
|
||||
return ac, out, errOut, reg
|
||||
}
|
||||
|
||||
func apiPaginateRequest() client.RawApiRequest {
|
||||
return client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test/v1/items",
|
||||
As: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
|
||||
t.Helper()
|
||||
wantBytes, err := json.MarshalIndent(want, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal expected JSON: %v", err)
|
||||
}
|
||||
wantBytes = append(wantBytes, '\n')
|
||||
if !bytes.Equal(got, wantBytes) {
|
||||
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
calls := 0
|
||||
wantTokens := []string{"", "next-1", "next-2"}
|
||||
for i, wantToken := range wantTokens {
|
||||
page := i + 1
|
||||
hasMore := page < len(wantTokens)
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = wantTokens[page]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(req *http.Request) {
|
||||
calls++
|
||||
if got := req.URL.Query().Get("page_token"); got != wantToken {
|
||||
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("pagination requests = %d, want 3", calls)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1"},
|
||||
map[string]interface{}{"id": "2"},
|
||||
map[string]interface{}{"id": "3"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
})
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ndjson",
|
||||
format: output.FormatNDJSON,
|
||||
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
format: output.FormatTable,
|
||||
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
format: output.FormatCSV,
|
||||
want: "id,name\n1,Alice\n2,Carol\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
if got := out.String(); got != tt.want {
|
||||
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
ac, _, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
sentinel := errors.New("page write failed")
|
||||
out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
|
||||
calls := 0
|
||||
for page := 1; page <= 2; page++ {
|
||||
hasMore := true
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": page}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = fmt.Sprintf("next-%d", page)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(*http.Request) {
|
||||
calls++
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("pagination requests = %d, want 2", calls)
|
||||
}
|
||||
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
|
||||
t.Fatalf("stdout bytes = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
})
|
||||
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
|
||||
if got := errOut.String(); got != wantWarning {
|
||||
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
|
||||
businessResponse := map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{"detail": "business failed"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "default_json", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: businessResponse,
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
|
||||
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
|
||||
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "stream_pages", format: output.FormatNDJSON},
|
||||
{name: "default_paginate_all", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want transport error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -352,6 +352,9 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
|
||||
})
|
||||
@@ -371,8 +374,33 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "binary response detected") {
|
||||
t.Error("expected binary response hint in stderr")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "saved_path") {
|
||||
t.Error("expected saved_path in output")
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String())
|
||||
}
|
||||
savedPath, _ := got["saved_path"].(string)
|
||||
if savedPath == "" {
|
||||
t.Fatalf("saved_path missing from output: %#v", got)
|
||||
}
|
||||
// The file must land inside the temporary cwd — this pins the isolation
|
||||
// contract: rolling back TestChdir would leave download.bin in the repo.
|
||||
wantDir, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath))
|
||||
if err != nil {
|
||||
t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err)
|
||||
}
|
||||
if gotDir != wantDir {
|
||||
t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir)
|
||||
}
|
||||
content, err := os.ReadFile(savedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved file: %v", err)
|
||||
}
|
||||
if string(content) != "fake-binary-content" {
|
||||
t.Errorf("saved file content = %q, want %q", content, "fake-binary-content")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,21 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
)
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
return newCmdAuth(f, nil)
|
||||
}
|
||||
|
||||
// NewCmdAuthWithRecovery creates the auth command with a build-local recovery
|
||||
// presenter while preserving NewCmdAuth's established function signature.
|
||||
func NewCmdAuthWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
return newCmdAuth(f, projector)
|
||||
}
|
||||
|
||||
func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "OAuth credentials and authorization management",
|
||||
@@ -40,10 +51,10 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
cmd.AddCommand(NewCmdAuthLogin(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthLogout(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthStatus(f, nil))
|
||||
cmd.AddCommand(newCmdAuthStatus(f, nil, projector))
|
||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthList(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthCheck(f, nil))
|
||||
cmd.AddCommand(newCmdAuthList(f, nil, projector))
|
||||
cmd.AddCommand(newCmdAuthCheck(f, nil, projector))
|
||||
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
)
|
||||
|
||||
// CheckOptions holds all inputs for auth check.
|
||||
@@ -24,6 +25,14 @@ type CheckOptions struct {
|
||||
|
||||
// NewCmdAuthCheck creates the auth check subcommand.
|
||||
func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Command {
|
||||
return newCmdAuthCheck(f, runF, nil)
|
||||
}
|
||||
|
||||
func newCmdAuthCheck(
|
||||
f *cmdutil.Factory,
|
||||
runF func(*CheckOptions) error,
|
||||
projector *recovery.Projector,
|
||||
) *cobra.Command {
|
||||
opts := &CheckOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -33,7 +42,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return authCheckRun(opts)
|
||||
return authCheckRunWithRecovery(opts, projector)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,6 +55,10 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
}
|
||||
|
||||
func authCheckRun(opts *CheckOptions) error {
|
||||
return authCheckRunWithRecovery(opts, nil)
|
||||
}
|
||||
|
||||
func authCheckRunWithRecovery(opts *CheckOptions, projector *recovery.Projector) error {
|
||||
f := opts.Factory
|
||||
|
||||
required := strings.Fields(opts.Scope)
|
||||
@@ -82,7 +95,7 @@ func authCheckRun(opts *CheckOptions) error {
|
||||
|
||||
ok := len(missing) == 0
|
||||
result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing}
|
||||
if len(missing) > 0 {
|
||||
if len(missing) > 0 && projector.CanReference(recovery.TargetAuthLogin) {
|
||||
result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
@@ -162,3 +165,70 @@ func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
|
||||
t.Errorf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_ConcealedLoginOmitsSuggestion(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_user",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
||||
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
||||
Scope: "im:message",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
visibleFactory, visibleStdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
if err := authCheckRun(&CheckOptions{
|
||||
Factory: visibleFactory,
|
||||
Scope: "calendar:calendar:read",
|
||||
}); output.ExitCodeOf(err) != 1 {
|
||||
t.Fatalf("default check exit = %d, want predicate miss exit 1", output.ExitCodeOf(err))
|
||||
}
|
||||
var visiblePayload map[string]any
|
||||
if err := json.Unmarshal(visibleStdout.Bytes(), &visiblePayload); err != nil {
|
||||
t.Fatalf("default stdout must be valid JSON: %v", err)
|
||||
}
|
||||
if suggestion, _ := visiblePayload["suggestion"].(string); !strings.Contains(suggestion, "auth login") {
|
||||
t.Fatalf("default output lost established login suggestion: %#v", visiblePayload)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandAuthLogin: surface.CommandConcealed,
|
||||
})
|
||||
err := authCheckRunWithRecovery(
|
||||
&CheckOptions{Factory: f, Scope: "calendar:calendar:read"},
|
||||
recovery.NewProjector(func() *surface.Plan { return plan }),
|
||||
)
|
||||
if got := output.ExitCodeOf(err); got != 1 {
|
||||
t.Fatalf("exit code = %d, want predicate miss exit 1", got)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr must stay empty, got:\n%s", stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if _, ok := payload["suggestion"]; ok {
|
||||
t.Fatalf("concealed auth/login left a dead suggestion: %#v", payload["suggestion"])
|
||||
}
|
||||
if missing, ok := payload["missing"].([]any); !ok || len(missing) != 1 {
|
||||
t.Fatalf("projection removed missing-scope facts: %#v", payload["missing"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
)
|
||||
|
||||
// ListOptions holds all inputs for auth list.
|
||||
@@ -24,6 +25,14 @@ type ListOptions struct {
|
||||
|
||||
// NewCmdAuthList creates the auth list subcommand.
|
||||
func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
|
||||
return newCmdAuthList(f, runF, nil)
|
||||
}
|
||||
|
||||
func newCmdAuthList(
|
||||
f *cmdutil.Factory,
|
||||
runF func(*ListOptions) error,
|
||||
projector *recovery.Projector,
|
||||
) *cobra.Command {
|
||||
opts := &ListOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -33,7 +42,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return authListRun(opts)
|
||||
return authListRunWithRecovery(opts, projector)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
@@ -43,6 +52,10 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
}
|
||||
|
||||
func authListRun(opts *ListOptions) error {
|
||||
return authListRunWithRecovery(opts, nil)
|
||||
}
|
||||
|
||||
func authListRunWithRecovery(opts *ListOptions, projector *recovery.Projector) error {
|
||||
f := opts.Factory
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
@@ -61,7 +74,7 @@ func authListRun(opts *ListOptions) error {
|
||||
// workspace-aware, so we pull the message+hint out of
|
||||
// NotConfiguredError() instead of hard-coding it.
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
if errors.As(projector.Render(core.NotConfiguredError()), &cfgErr) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||
if cfgErr.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
|
||||
@@ -80,7 +93,11 @@ func authListRun(opts *ListOptions) error {
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
fmt.Fprint(f.IOStreams.ErrOut, "No logged-in users.")
|
||||
if projector.CanReference(recovery.TargetAuthLogin) {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, " Run `lark-cli auth login` to log in.")
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
)
|
||||
|
||||
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
|
||||
@@ -126,7 +128,49 @@ func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "No logged-in users") {
|
||||
t.Errorf("stderr = %q, want no-users hint", stderr.String())
|
||||
if got := stderr.String(); !strings.Contains(got, "No logged-in users") ||
|
||||
!strings.Contains(got, "auth login") {
|
||||
t.Errorf("stderr = %q, want established no-users login hint", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_ConcealedLoginKeepsStateWithoutDeadRecovery(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandAuthLogin: surface.CommandConcealed,
|
||||
})
|
||||
if err := authListRunWithRecovery(
|
||||
&ListOptions{Factory: f},
|
||||
recovery.NewProjector(func() *surface.Plan { return plan }),
|
||||
); err != nil {
|
||||
t.Fatalf("auth list should remain a successful probe: %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout must stay empty, got:\n%s", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "No logged-in users") ||
|
||||
strings.Contains(got, "auth login") {
|
||||
t.Fatalf("concealed recovery = %q, want state without dead login action", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_ConcealedConfigInitProjectsManualErrorOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandConfigInit: surface.CommandConcealed,
|
||||
})
|
||||
if err := authListRunWithRecovery(
|
||||
&ListOptions{Factory: f},
|
||||
recovery.NewProjector(func() *surface.Plan { return plan }),
|
||||
); err != nil {
|
||||
t.Fatalf("auth list should remain a successful probe: %v", err)
|
||||
}
|
||||
if got := stderr.String(); strings.Contains(got, "config init") {
|
||||
t.Fatalf("manual config error rendering retained concealed recovery: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
)
|
||||
|
||||
// StatusOptions holds all inputs for auth status.
|
||||
@@ -22,6 +23,14 @@ type StatusOptions struct {
|
||||
|
||||
// NewCmdAuthStatus creates the auth status subcommand.
|
||||
func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
|
||||
return newCmdAuthStatus(f, runF, nil)
|
||||
}
|
||||
|
||||
func newCmdAuthStatus(
|
||||
f *cmdutil.Factory,
|
||||
runF func(*StatusOptions) error,
|
||||
projector *recovery.Projector,
|
||||
) *cobra.Command {
|
||||
opts := &StatusOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -31,7 +40,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return authStatusRun(opts)
|
||||
return authStatusRun(opts, projector)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -42,7 +51,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
return cmd
|
||||
}
|
||||
|
||||
func authStatusRun(opts *StatusOptions) error {
|
||||
func authStatusRun(opts *StatusOptions, projector *recovery.Projector) error {
|
||||
f := opts.Factory
|
||||
|
||||
config, err := f.Config()
|
||||
@@ -60,11 +69,14 @@ func authStatusRun(opts *StatusOptions) error {
|
||||
"defaultAs": defaultAs,
|
||||
}
|
||||
|
||||
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
||||
diagnostics := identitydiag.FilterRecovery(
|
||||
identitydiag.Diagnose(context.Background(), f, config, opts.Verify),
|
||||
projector.CanReference,
|
||||
)
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
addStatusNote(result, diagnostics)
|
||||
addStatusNote(result, diagnostics, projector.CanReference(recovery.TargetAuthLogin))
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
@@ -106,13 +118,21 @@ func addEffectiveVerification(result map[string]interface{}, d identitydiag.Resu
|
||||
}
|
||||
}
|
||||
|
||||
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
|
||||
func addStatusNote(result map[string]interface{}, d identitydiag.Result, canAuthLogin bool) {
|
||||
switch {
|
||||
case !d.User.Available && d.Bot.Available:
|
||||
result["note"] = "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls. Run `lark-cli auth login` to enable user identity."
|
||||
note := "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls."
|
||||
if canAuthLogin {
|
||||
note += " Run `lark-cli auth login` to enable user identity."
|
||||
}
|
||||
result["note"] = note
|
||||
case d.User.Status == identitydiag.StatusNeedsRefresh:
|
||||
result["note"] = "User identity needs refresh and will be refreshed automatically on the next user API call."
|
||||
case !d.User.Available && !d.Bot.Available:
|
||||
result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`."
|
||||
note := "No usable identity is available. Configure bot credentials"
|
||||
if canAuthLogin {
|
||||
note += " or run `lark-cli auth login`"
|
||||
}
|
||||
result["note"] = note + "."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}, nil); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil {
|
||||
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}, nil); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
|
||||
46
cmd/auth/testmain_test.go
Normal file
46
cmd/auth/testmain_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates auth command tests from the host machine: config, logs
|
||||
// and the registry cache are redirected to a temp dir, then the registry is
|
||||
// seeded from the tracked fixture and initialized eagerly. Domain-completion
|
||||
// tests read the registry, so without seeding a clean checkout would either
|
||||
// fail or trigger a remote metadata fetch.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*")
|
||||
if err != nil {
|
||||
println("cmd/auth test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
println("cmd/auth test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
|
||||
println("cmd/auth test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd/auth test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||
@@ -70,3 +73,18 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
|
||||
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDeferredBootstrapProfileError(t *testing.T) {
|
||||
if !isDeferredBootstrapProfileError(errors.New("flag needs an argument: --profile")) {
|
||||
t.Fatal("missing --profile value must be deferred to the completed Cobra tree")
|
||||
}
|
||||
for _, err := range []error{
|
||||
nil,
|
||||
errors.New("flag needs an argument: --future"),
|
||||
errors.New("invalid argument for --profile"),
|
||||
} {
|
||||
if isDeferredBootstrapProfileError(err) {
|
||||
t.Fatalf("unexpected deferred bootstrap error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
192
cmd/build.go
192
cmd/build.go
@@ -21,6 +21,7 @@ import (
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/cmd/whoami"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
@@ -28,7 +29,12 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/skillpolicy"
|
||||
"github.com/larksuite/cli/internal/skillref"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -37,14 +43,29 @@ import (
|
||||
type BuildOption func(*buildConfig)
|
||||
|
||||
type buildConfig struct {
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
globals GlobalOptions
|
||||
skipPlugins bool
|
||||
skipStrictMode bool
|
||||
skipService bool
|
||||
serviceCatalog *apicatalog.Catalog
|
||||
startupBrand core.LarkBrand
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
globals GlobalOptions
|
||||
presentation restrictionPresentationConfig
|
||||
skipPlugins bool
|
||||
skipStrictMode bool
|
||||
skipService bool
|
||||
deferStartup bool
|
||||
serviceCatalog *apicatalog.Catalog
|
||||
startupBrand core.LarkBrand
|
||||
startupBrandSet bool
|
||||
hideProfileSet bool
|
||||
}
|
||||
|
||||
// buildRuntime owns presentation state for exactly one command tree. Factory
|
||||
// remains the business dependency container; distribution policy never enters
|
||||
// it. The embedded pointer preserves convenient access to Factory fields in
|
||||
// cmd-internal tests without exposing the surface plan to business packages.
|
||||
type buildRuntime struct {
|
||||
*cmdutil.Factory
|
||||
surface *surface.Plan
|
||||
recovery *recovery.Projector
|
||||
skillReferences *skillref.Resolver
|
||||
}
|
||||
|
||||
// WithStartupBrand initializes the API registry with the given brand before
|
||||
@@ -55,6 +76,7 @@ type buildConfig struct {
|
||||
func WithStartupBrand(brand core.LarkBrand) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.startupBrand = brand
|
||||
c.startupBrandSet = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +107,12 @@ var embeddedSkillContent fs.FS
|
||||
// supply its own skill content.
|
||||
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||
|
||||
// SetEmbeddedAffordanceContent registers the per-domain command guidance tree.
|
||||
// Wrapper mains should wire the repository's affordance directory alongside
|
||||
// embedded skills so generic --help presentation remains complete and skill
|
||||
// references follow the composed distribution.
|
||||
func SetEmbeddedAffordanceContent(fsys fs.FS) { affordance.SetSource(fsys) }
|
||||
|
||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||
// When hide is true the flag stays registered (so existing invocations still
|
||||
// parse) but is omitted from help and shell completion. Typically called as
|
||||
@@ -92,6 +120,7 @@ func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||
func HideProfile(hide bool) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.globals.HideProfile = hide
|
||||
c.hideProfileSet = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,11 +176,11 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti
|
||||
// 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
|
||||
// Returns (runtime, rootCmd, registry). The registry is nil when plugin
|
||||
// install failed (FailClosed guard installed) or when no plugin produced
|
||||
// hooks; callers that wire Shutdown emit must nil-check before calling
|
||||
// hook.Emit.
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command, *hook.Registry) {
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*buildRuntime, *cobra.Command, *hook.Registry) {
|
||||
// cfg.globals.Profile is left zero here; it's bound to the --profile
|
||||
// flag in RegisterGlobalFlags and filled by cobra's parse step.
|
||||
cfg := &buildConfig{}
|
||||
@@ -160,6 +189,16 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
o(cfg)
|
||||
}
|
||||
}
|
||||
return buildInternalWithConfig(ctx, inv, cfg)
|
||||
}
|
||||
|
||||
// buildInternalWithConfig assembles one command tree from an already-applied
|
||||
// option snapshot. Execute uses this boundary so stateful BuildOptions are
|
||||
// never evaluated once for bootstrap inspection and a second time for Build.
|
||||
func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, cfg *buildConfig) (*buildRuntime, *cobra.Command, *hook.Registry) {
|
||||
if cfg == nil {
|
||||
cfg = &buildConfig{}
|
||||
}
|
||||
// Default streams when WithIO is not supplied so the root command's
|
||||
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
|
||||
// partial streams internally; keep both in sync so cfg.streams reflects
|
||||
@@ -167,18 +206,28 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
if cfg.streams == nil {
|
||||
cfg.streams = cmdutil.SystemIO()
|
||||
}
|
||||
|
||||
// Initialize the registry brand before anything touches the runtime
|
||||
// catalog (its sync.Once would otherwise lock onto the Feishu default).
|
||||
if cfg.startupBrand != "" {
|
||||
registry.InitWithBrand(cfg.startupBrand)
|
||||
}
|
||||
|
||||
// Reset the legacy process-global diagnostic snapshots before paths that
|
||||
// may return early. Distribution presentation state is deliberately not
|
||||
// stored here; it belongs to this build's immutable surface plan.
|
||||
cmdpolicy.SetActive(nil)
|
||||
internalplatform.SetActiveInventory(nil)
|
||||
|
||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
f.SkillContent = embeddedSkillContent
|
||||
runtime := &buildRuntime{Factory: f}
|
||||
runtime.recovery = recovery.NewProjector(func() *surface.Plan {
|
||||
return runtime.surface
|
||||
})
|
||||
f.Recovery = runtime.recovery
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
@@ -195,7 +244,17 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
// rootUsageTemplate.
|
||||
rootCmd.SetUsageTemplate(rootUsageTemplate)
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
// Framework-generated skill pointers read this build's final content and
|
||||
// exact command surface lazily. A second Build therefore cannot rewrite
|
||||
// help rendered by the first tree.
|
||||
installTipsHelpFunc(rootCmd, func() fs.FS {
|
||||
if !runtime.surface.CanReference(surface.CommandSkillsRead) {
|
||||
return nil
|
||||
}
|
||||
return runtime.SkillContent
|
||||
}, func() *skillref.Resolver {
|
||||
return runtime.skillReferences
|
||||
}, runtime.recovery)
|
||||
rootCmd.SilenceErrors = true
|
||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
|
||||
@@ -211,11 +270,11 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
f.CurrentCommand = cmd
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfigWithRecovery(f, runtime.recovery))
|
||||
rootCmd.AddCommand(auth.NewCmdAuthWithRecovery(f, runtime.recovery))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctorWithRecovery(f, runtime.recovery))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoamiWithRecovery(f, runtime.recovery))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
@@ -231,52 +290,93 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
}
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
groupRootCommands(rootCmd)
|
||||
classifyRootCommands(rootCmd)
|
||||
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
|
||||
// before printing help; non-bare invocations and non-TTY are unaffected.
|
||||
installRootUpgradePrompt(f, rootCmd)
|
||||
installRootUpgradePrompt(f, rootCmd, runtime.recovery)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
if cfg.skipPlugins {
|
||||
recordInventory(nil)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
var (
|
||||
installResult *internalplatform.InstallResult
|
||||
pluginRules []cmdpolicy.PluginRule
|
||||
pluginSkills []skillpolicy.PluginSkill
|
||||
hookRegistry *hook.Registry
|
||||
denied map[string]cmdpolicy.Denial
|
||||
)
|
||||
|
||||
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
|
||||
if installErr != nil {
|
||||
installPluginInstallErrorGuard(rootCmd, installErr)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
var pluginRules []cmdpolicy.PluginRule
|
||||
var registry *hook.Registry
|
||||
if installResult != nil {
|
||||
pluginRules = installResult.PluginRules
|
||||
registry = installResult.Registry
|
||||
}
|
||||
|
||||
// Policy errors fail-CLOSED when a plugin contributed (security
|
||||
// intent must not be silently dropped); yaml-only errors fail-OPEN
|
||||
// with a warning so a typo can't lock the user out.
|
||||
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
|
||||
if len(pluginRules) > 0 {
|
||||
installPluginConflictGuard(rootCmd, err)
|
||||
return f, rootCmd, nil
|
||||
if !cfg.skipPlugins {
|
||||
var installErr error
|
||||
installResult, installErr = installPluginsAndHooks(cfg.streams.ErrOut)
|
||||
if installErr != nil {
|
||||
installPluginInstallErrorGuard(rootCmd, installErr)
|
||||
return finalizeFailedBuild(runtime, rootCmd)
|
||||
}
|
||||
if installResult != nil {
|
||||
pluginRules = installResult.PluginRules
|
||||
pluginSkills = installResult.PluginSkills
|
||||
hookRegistry = installResult.Registry
|
||||
}
|
||||
|
||||
// Policy errors fail-CLOSED when a plugin contributed (security
|
||||
// intent must not be silently dropped); yaml-only errors fail-OPEN
|
||||
// with a warning so a typo can't lock the user out.
|
||||
var policyErr error
|
||||
denied, policyErr = applyUserPolicyPruning(rootCmd, pluginRules)
|
||||
if policyErr != nil {
|
||||
if len(pluginRules) > 0 {
|
||||
installPluginConflictGuard(rootCmd, policyErr)
|
||||
return finalizeFailedBuild(runtime, rootCmd)
|
||||
}
|
||||
warnPolicyError(cfg.streams.ErrOut, policyErr)
|
||||
}
|
||||
warnPolicyError(cfg.streams.ErrOut, err)
|
||||
}
|
||||
|
||||
if registry != nil {
|
||||
if err := wireHooks(ctx, rootCmd, registry); err != nil {
|
||||
// Presentation is an explicit host projection over the exact enforcement
|
||||
// decisions. With no opt-in, legacy Restrict and YAML policy behavior is
|
||||
// mechanically unchanged.
|
||||
var hasConcealedCommands bool
|
||||
runtime.surface, hasConcealedCommands = applyDistributionPresentation(rootCmd, cfg.presentation, denied)
|
||||
|
||||
// Resolve skill assets and canonical references before installing hooks.
|
||||
// A declared customization is a build-integrity boundary: failure must
|
||||
// happen before Startup so no lifecycle side effect is stranded.
|
||||
skillResolution, skillErr := skillpolicy.ResolveWithReferences(embeddedSkillContent, pluginSkills)
|
||||
if skillErr != nil {
|
||||
installPluginSkillErrorGuard(rootCmd, skillErr)
|
||||
return finalizeFailedBuild(runtime, rootCmd)
|
||||
}
|
||||
f.SkillContent = skillResolution.Content
|
||||
runtime.skillReferences = skillResolution.References
|
||||
|
||||
// Install hooks only on business commands. The concealment-specific help
|
||||
// command is attached afterwards, preserving Cobra's historical contract
|
||||
// that help is not observed or wrapped by plugins.
|
||||
if hookRegistry != nil {
|
||||
installHooks(rootCmd, hookRegistry)
|
||||
}
|
||||
if hasConcealedCommands {
|
||||
installHelpCommand(rootCmd)
|
||||
}
|
||||
finalizeRootCommandGroups(rootCmd, runtime.surface)
|
||||
|
||||
if hookRegistry != nil && !cfg.deferStartup {
|
||||
if err := emitStartup(ctx, hookRegistry); err != nil {
|
||||
installPluginLifecycleErrorGuard(rootCmd, err)
|
||||
return f, rootCmd, nil
|
||||
recordInventory(installResult)
|
||||
return runtime, rootCmd, nil
|
||||
}
|
||||
}
|
||||
|
||||
recordInventory(installResult)
|
||||
return f, rootCmd, registry
|
||||
return runtime, rootCmd, hookRegistry
|
||||
}
|
||||
|
||||
func finalizeFailedBuild(runtime *buildRuntime, root *cobra.Command) (*buildRuntime, *cobra.Command, *hook.Registry) {
|
||||
finalizeRootCommandGroups(root, runtime.surface)
|
||||
return runtime, root, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -28,6 +29,10 @@ func TestBuild_ExternalAPI(t *testing.T) {
|
||||
// Exercise SetDefaultFS both directions. Passing nil restores the OS FS.
|
||||
SetDefaultFS(vfs.OsFs{})
|
||||
SetDefaultFS(nil)
|
||||
SetEmbeddedAffordanceContent(fstest.MapFS{
|
||||
"docs.md": {Data: []byte("# docs\n")},
|
||||
})
|
||||
t.Cleanup(func() { SetEmbeddedAffordanceContent(nil) })
|
||||
|
||||
var in, out, errOut bytes.Buffer
|
||||
rootCmd := Build(
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -59,6 +60,14 @@ type BindOptions struct {
|
||||
|
||||
// NewCmdConfigBind creates the config bind subcommand.
|
||||
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
|
||||
return newCmdConfigBind(f, runF, nil)
|
||||
}
|
||||
|
||||
func newCmdConfigBind(
|
||||
f *cmdutil.Factory,
|
||||
runF func(*BindOptions) error,
|
||||
projector *recovery.Projector,
|
||||
) *cobra.Command {
|
||||
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -98,7 +107,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return configBindRun(opts)
|
||||
return configBindRunWithRecovery(opts, projector)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -116,6 +125,10 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
// helper whose signature declares its contract; the body reads as the shape of
|
||||
// the bind flow itself, not its mechanics.
|
||||
func configBindRun(opts *BindOptions) error {
|
||||
return configBindRunWithRecovery(opts, nil)
|
||||
}
|
||||
|
||||
func configBindRunWithRecovery(opts *BindOptions, projector *recovery.Projector) error {
|
||||
if err := validateBindFlags(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -154,7 +167,7 @@ func configBindRun(opts *BindOptions) error {
|
||||
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
|
||||
noticeUserDefaultRisk(opts)
|
||||
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath, projector)
|
||||
}
|
||||
|
||||
// existingBinding is the outcome of checking whether a workspace was already
|
||||
@@ -404,7 +417,13 @@ func priorLang(previousConfigBytes []byte) i18n.Lang {
|
||||
// any), and a JSON success envelope. Cleanup runs only after the new config
|
||||
// is durably written — if anything fails earlier, the old workspace stays
|
||||
// usable.
|
||||
func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
|
||||
func commitBinding(
|
||||
opts *BindOptions,
|
||||
appConfig *core.AppConfig,
|
||||
previousConfigBytes []byte,
|
||||
source, configPath string,
|
||||
projector *recovery.Projector,
|
||||
) error {
|
||||
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
||||
|
||||
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
||||
@@ -462,7 +481,7 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
||||
case "bot-only":
|
||||
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
|
||||
case "user-default":
|
||||
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
|
||||
envelope["message"] = userDefaultBindMessage(prefMsg, appConfig.AppId, display, projector)
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(envelope)
|
||||
@@ -470,6 +489,17 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
||||
return nil
|
||||
}
|
||||
|
||||
func userDefaultBindMessage(
|
||||
messages *bindMsg,
|
||||
appID, display string,
|
||||
projector *recovery.Projector,
|
||||
) string {
|
||||
if projector.CanReference(recovery.TargetAuthLogin) {
|
||||
return fmt.Sprintf(messages.MessageUserDefault, appID, display, display)
|
||||
}
|
||||
return fmt.Sprintf(messages.MessageUserDefaultFallback, appID, display)
|
||||
}
|
||||
|
||||
// cleanupKeychainFromData removes keychain entries referenced by a previous
|
||||
// config snapshot, skipping any entry whose keychain ID is still in use by
|
||||
// the new app config. This prevents rebinding the same appId from deleting
|
||||
|
||||
@@ -37,6 +37,9 @@ type bindMsg struct {
|
||||
// MessageBotOnly format: app_id, source display name, brand.
|
||||
// MessageUserDefault format: app_id, source display name, source display
|
||||
// name (second source ref anchors the "run in this chat" directive).
|
||||
// MessageUserDefaultFallback format: app_id, source display name. It keeps
|
||||
// the completed bind facts but uses target-free recovery when auth/login
|
||||
// is not part of this distribution.
|
||||
// MessageUserDefault directs the Agent at the blocking single-call
|
||||
// `auth login --recommend` flow: the CLI streams verification_url to
|
||||
// stderr, which Agent runtimes (OpenClaw, Hermes) relay to the user in
|
||||
@@ -44,8 +47,9 @@ type bindMsg struct {
|
||||
// The Agent also needs an explicit "do not navigate the URL yourself"
|
||||
// guard — its own browser is sandboxed and cannot complete the user's
|
||||
// authorization.
|
||||
MessageBotOnly string
|
||||
MessageUserDefault string
|
||||
MessageBotOnly string
|
||||
MessageUserDefault string
|
||||
MessageUserDefaultFallback string
|
||||
|
||||
// Identity preset (collapses strict-mode + default-as into one choice).
|
||||
// IdentityBotOnly/IdentityUserDefault are short, single-line labels for
|
||||
@@ -108,8 +112,9 @@ var bindMsgZh = &bindMsg{
|
||||
ConflictCancel: "保留当前配置",
|
||||
ConflictCancelled: "已保留当前配置",
|
||||
|
||||
MessageBotOnly: "已绑定应用 %s 到 %s,可立即以应用(bot)身份调用%s API,现在可以继续执行用户的请求。",
|
||||
MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`。该命令会在 stderr 打出 verification_url 后阻塞等待用户授权;请将此链接原样发给用户在其浏览器中完成授权(不要自己调 browser_navigate 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
|
||||
MessageBotOnly: "已绑定应用 %s 到 %s,可立即以应用(bot)身份调用%s API,现在可以继续执行用户的请求。",
|
||||
MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`。该命令会在 stderr 打出 verification_url 后阻塞等待用户授权;请将此链接原样发给用户在其浏览器中完成授权(不要自己调 browser_navigate 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
|
||||
MessageUserDefaultFallback: "已绑定应用 %s 到 %s。请通过该发行版支持的授权流程获取或刷新用户凭证,然后再继续执行用户的请求。",
|
||||
|
||||
SelectIdentity: "你希望 AI 如何与你协作?",
|
||||
IdentityBotOnly: "以机器人身份",
|
||||
@@ -144,8 +149,9 @@ var bindMsgEn = &bindMsg{
|
||||
ConflictCancel: "Keep current config",
|
||||
ConflictCancelled: "Current config kept. No changes made.",
|
||||
|
||||
MessageBotOnly: "Bound app %s to %s. The %s app (bot) identity is ready — you can now continue with the user's request.",
|
||||
MessageUserDefault: "Bound app %s to %s. Next, in this %s chat, run `lark-cli auth login --recommend`. The command prints the verification URL to stderr and then blocks until the user authorizes it; relay the URL to the user so they can approve it in their own browser (do not call browser_navigate or any tool that opens a browser yourself — your browser is sandboxed and cannot complete the authorization). The command returns automatically once authorization completes.",
|
||||
MessageBotOnly: "Bound app %s to %s. The %s app (bot) identity is ready — you can now continue with the user's request.",
|
||||
MessageUserDefault: "Bound app %s to %s. Next, in this %s chat, run `lark-cli auth login --recommend`. The command prints the verification URL to stderr and then blocks until the user authorizes it; relay the URL to the user so they can approve it in their own browser (do not call browser_navigate or any tool that opens a browser yourself — your browser is sandboxed and cannot complete the authorization). The command returns automatically once authorization completes.",
|
||||
MessageUserDefaultFallback: "Bound app %s to %s. Obtain or refresh a user credential through this distribution's supported authorization flow before continuing with the user's request.",
|
||||
|
||||
SelectIdentity: "How should the AI work with you?",
|
||||
IdentityBotOnly: "As bot",
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
)
|
||||
|
||||
// runHermesBindWithIdentity boots a Hermes-shaped fake env, runs `config bind`
|
||||
@@ -60,3 +62,29 @@ func TestConfigBindRun_BotOnlyIdentity_NoImpersonationWarning(t *testing.T) {
|
||||
t.Errorf("bot-only bind must NOT warn about impersonation; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDefaultBindMessageProjectsConcealedLogin(t *testing.T) {
|
||||
visible := userDefaultBindMessage(bindMsgEn, "cli_test", "Hermes", nil)
|
||||
if !strings.Contains(visible, "lark-cli auth login --recommend") {
|
||||
t.Fatalf("default message lost established login action: %q", visible)
|
||||
}
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandAuthLogin: surface.CommandConcealed,
|
||||
})
|
||||
concealed := userDefaultBindMessage(
|
||||
bindMsgEn,
|
||||
"cli_test",
|
||||
"Hermes",
|
||||
recovery.NewProjector(func() *surface.Plan { return plan }),
|
||||
)
|
||||
if strings.Contains(concealed, "auth login") ||
|
||||
!strings.Contains(concealed, "supported authorization flow") {
|
||||
t.Fatalf("concealed message = %q, want target-free authorization fallback", concealed)
|
||||
}
|
||||
for _, want := range []string{"cli_test", "Hermes"} {
|
||||
if !strings.Contains(concealed, want) {
|
||||
t.Errorf("concealed message lost binding fact %q: %q", want, concealed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,22 @@ package config
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCmdConfig creates the config command with subcommands.
|
||||
func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
return newCmdConfig(f, nil)
|
||||
}
|
||||
|
||||
// NewCmdConfigWithRecovery creates the config command with build-local
|
||||
// recovery projection while preserving NewCmdConfig's established signature.
|
||||
func NewCmdConfigWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
return newCmdConfig(f, projector)
|
||||
}
|
||||
|
||||
func newCmdConfig(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Global CLI configuration management",
|
||||
@@ -26,11 +37,12 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.AddCommand(NewCmdConfigInit(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigBind(f, nil))
|
||||
cmd.AddCommand(newCmdConfigBind(f, nil, projector))
|
||||
cmd.AddCommand(NewCmdConfigRemove(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigRiskControl(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
)
|
||||
|
||||
type noopConfigKeychain struct{}
|
||||
@@ -564,3 +566,59 @@ func TestPrintLangPreferenceConfirmation(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The "no active profile" producer annotates its profile/list recovery target.
|
||||
// Rendering against one build's surface filters a clone without mutating the
|
||||
// value another command tree may render.
|
||||
func TestConfigShowRun_ProfileHintUsesBuildLocalSurface(t *testing.T) {
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
source := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
var original *errs.ConfigError
|
||||
if !errors.As(source, &original) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T %v", source, source)
|
||||
}
|
||||
if original.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Fatalf("subtype = %q, want not_configured", original.Subtype)
|
||||
}
|
||||
if !strings.Contains(original.Hint, "lark-cli profile list") {
|
||||
t.Fatalf("producer hint = %q, want profile list", original.Hint)
|
||||
}
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandProfileList: surface.CommandConcealed,
|
||||
})
|
||||
var concealed *errs.ConfigError
|
||||
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
|
||||
t.Fatalf("rendered error = %T, want *errs.ConfigError", rendered)
|
||||
}
|
||||
if concealed == original {
|
||||
t.Fatal("Render must clone the typed error")
|
||||
}
|
||||
if strings.Contains(concealed.Hint, "profile list") ||
|
||||
!strings.Contains(concealed.Hint, "select or configure an available profile") {
|
||||
t.Errorf("concealed hint = %q, want target-free profile recovery", concealed.Hint)
|
||||
}
|
||||
|
||||
var visible *errs.ConfigError
|
||||
if !errors.As(recovery.Render(source, nil), &visible) ||
|
||||
!strings.Contains(visible.Hint, "lark-cli profile list") {
|
||||
t.Errorf("visible render must keep profile list, got %+v", visible)
|
||||
}
|
||||
if !strings.Contains(original.Hint, "lark-cli profile list") {
|
||||
t.Errorf("concealed render mutated source hint: %q", original.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
)
|
||||
|
||||
// ConfigInitOptions holds all inputs for config init.
|
||||
@@ -40,13 +41,44 @@ type ConfigInitOptions struct {
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
|
||||
// ForceInit overrides the agent-workspace guard. Without it, running
|
||||
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
||||
// at config bind — which is what AI agents almost always want. Manual
|
||||
// users with a legitimate need for a separate app can pass --force-init
|
||||
// to bypass.
|
||||
// init under OPENCLAW_HOME / HERMES_HOME refuses so the distribution's
|
||||
// supported Agent-app setup flow remains the default. Manual users with
|
||||
// a legitimate need for a separate app can pass --force-init to bypass.
|
||||
ForceInit bool
|
||||
}
|
||||
|
||||
const (
|
||||
configInitLongPrefix = `Initialize configuration (app-id / app-secret-stdin / brand).
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command`
|
||||
|
||||
configInitBindGuidance = `
|
||||
refuses by default — use 'lark-cli config bind' to bind to the Agent's
|
||||
existing app instead of creating a parallel one.`
|
||||
|
||||
configInitBindFallback = `
|
||||
refuses by default to avoid creating a parallel app alongside Agent-managed
|
||||
credentials. Reuse the Agent's existing app through this distribution's
|
||||
supported setup flow.`
|
||||
|
||||
configInitBindSuffix = ` Pass --force-init only
|
||||
if the user explicitly wants a separate app inside the Agent workspace.`
|
||||
|
||||
configInitFallbackSuffix = ` Pass --force-init only if the user explicitly wants a
|
||||
separate app inside the Agent workspace.`
|
||||
|
||||
configInitLongWithBind = configInitLongPrefix + configInitBindGuidance + configInitBindSuffix
|
||||
configInitLongWithoutBind = configInitLongPrefix + configInitBindFallback + configInitFallbackSuffix
|
||||
|
||||
forceInitUsageWithBind = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app"
|
||||
|
||||
forceInitUsageWithoutBind = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME) only when the user explicitly wants a separate app"
|
||||
)
|
||||
|
||||
// NewCmdConfigInit creates the config init subcommand.
|
||||
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
|
||||
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN}
|
||||
@@ -54,16 +86,7 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize configuration (app-id / app-secret-stdin / brand)",
|
||||
Long: `Initialize configuration (app-id / app-secret-stdin / brand).
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
|
||||
refuses by default — use 'lark-cli config bind' to bind to the Agent's
|
||||
existing app instead of creating a parallel one. Pass --force-init only
|
||||
if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
Long: configInitLongWithBind,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
@@ -86,12 +109,30 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, forceInitUsageWithBind)
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ProjectInitHelp keeps the default command-specific guidance intact and
|
||||
// replaces it only when this build conceals config bind. The config package
|
||||
// owns both variants; the root presentation pass supplies the build-local
|
||||
// availability decision after plugin policy has finalized the command tree.
|
||||
func ProjectInitHelp(cmd *cobra.Command, canReferenceBind bool) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
long, forceInitUsage := configInitLongWithBind, forceInitUsageWithBind
|
||||
if !canReferenceBind {
|
||||
long, forceInitUsage = configInitLongWithoutBind, forceInitUsageWithoutBind
|
||||
}
|
||||
cmd.Long = long
|
||||
if flag := cmd.Flags().Lookup("force-init"); flag != nil {
|
||||
flag.Usage = forceInitUsage
|
||||
}
|
||||
}
|
||||
|
||||
// printLangPreferenceConfirmation echoes the set preference to stderr, only
|
||||
// when --lang explicitly set a non-empty value.
|
||||
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
|
||||
@@ -125,9 +166,14 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
if ws.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured,
|
||||
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()).
|
||||
WithHint("see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.")
|
||||
return recovery.Attach(
|
||||
errs.NewConfigError(errs.SubtypeNotConfigured,
|
||||
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
|
||||
recovery.Join(" ",
|
||||
recovery.Command(recovery.TargetConfigBind, "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead."),
|
||||
recovery.Text("Pass --force-init only if the user explicitly wants a separate app in this workspace."),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
)
|
||||
|
||||
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
||||
@@ -44,6 +46,82 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_BindRecoveryUsesBuildLocalSurface(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
source := guardAgentWorkspace(&ConfigInitOptions{})
|
||||
var original *errs.ConfigError
|
||||
if !errors.As(source, &original) {
|
||||
t.Fatalf("guardAgentWorkspace() error = %T, want *errs.ConfigError", source)
|
||||
}
|
||||
const visibleHint = "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace."
|
||||
if original.Hint != visibleHint {
|
||||
t.Fatalf("producer hint = %q, want %q", original.Hint, visibleHint)
|
||||
}
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandConfigBind: surface.CommandConcealed,
|
||||
})
|
||||
var concealed *errs.ConfigError
|
||||
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
|
||||
t.Fatalf("rendered error = %T, want *errs.ConfigError", rendered)
|
||||
}
|
||||
const forceInitHint = "Pass --force-init only if the user explicitly wants a separate app in this workspace."
|
||||
if concealed.Hint != forceInitHint {
|
||||
t.Errorf("concealed hint = %q, want %q", concealed.Hint, forceInitHint)
|
||||
}
|
||||
if original.Hint != visibleHint {
|
||||
t.Errorf("concealed render mutated producer hint: %q", original.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectInitHelpPreservesDefaultAndProjectsConcealedBind(t *testing.T) {
|
||||
cmd := NewCmdConfigInit(nil, nil)
|
||||
forceInit := cmd.Flags().Lookup("force-init")
|
||||
if forceInit == nil {
|
||||
t.Fatal("config init command has no --force-init flag")
|
||||
}
|
||||
const defaultLong = `Initialize configuration (app-id / app-secret-stdin / brand).
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
|
||||
refuses by default — use 'lark-cli config bind' to bind to the Agent's
|
||||
existing app instead of creating a parallel one. Pass --force-init only
|
||||
if the user explicitly wants a separate app inside the Agent workspace.`
|
||||
const defaultForceInitUsage = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app"
|
||||
if cmd.Long != defaultLong || forceInit.Usage != defaultForceInitUsage {
|
||||
t.Fatalf("default help lost config bind recovery:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
|
||||
}
|
||||
|
||||
ProjectInitHelp(cmd, false)
|
||||
const concealedLong = `Initialize configuration (app-id / app-secret-stdin / brand).
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
|
||||
refuses by default to avoid creating a parallel app alongside Agent-managed
|
||||
credentials. Reuse the Agent's existing app through this distribution's
|
||||
supported setup flow. Pass --force-init only if the user explicitly wants a
|
||||
separate app inside the Agent workspace.`
|
||||
const concealedForceInitUsage = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME) only when the user explicitly wants a separate app"
|
||||
if cmd.Long != concealedLong || forceInit.Usage != concealedForceInitUsage {
|
||||
t.Fatalf("concealed help was not projected:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
|
||||
}
|
||||
if strings.Contains(cmd.Long, "config bind") || strings.Contains(forceInit.Usage, "config bind") {
|
||||
t.Fatalf("concealed help retained config bind:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
|
||||
}
|
||||
|
||||
ProjectInitHelp(cmd, true)
|
||||
if cmd.Long != defaultLong || forceInit.Usage != defaultForceInitUsage {
|
||||
t.Fatalf("visible projection did not restore default help:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
|
||||
t.Setenv("HERMES_HOME", t.TempDir())
|
||||
|
||||
|
||||
@@ -85,6 +85,9 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
|
||||
if len(p.Rules) > 0 {
|
||||
entry["rules"] = p.Rules
|
||||
}
|
||||
if p.EmbeddedSkills != nil {
|
||||
entry["embedded_skills"] = p.EmbeddedSkills
|
||||
}
|
||||
entry["hooks"] = map[string]any{
|
||||
"observers": p.Observers,
|
||||
"wrappers": p.Wrappers,
|
||||
|
||||
93
cmd/config/plugins_test.go
Normal file
93
cmd/config/plugins_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// config plugins show must surface a plugin's EmbeddedSkills contribution in
|
||||
// the rendered JSON, not only in the internal inventory struct: this command is
|
||||
// the operator's window into what a fork trimmed, so the Allow/Remove/Overlay/
|
||||
// Base summary has to reach stdout. Guards the render layer, which asserting the
|
||||
// inventory struct alone does not exercise.
|
||||
func TestConfigPluginsShow_RendersEmbeddedSkills(t *testing.T) {
|
||||
internalplatform.SetActiveInventory(&internalplatform.Inventory{
|
||||
Plugins: []internalplatform.PluginEntry{{
|
||||
Name: "acme",
|
||||
Version: "1.0",
|
||||
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
|
||||
EmbeddedSkills: &internalplatform.SkillsOverlayView{
|
||||
Allow: []string{"lark-im"},
|
||||
Remove: []string{"lark-a"},
|
||||
Overlay: true,
|
||||
Base: true,
|
||||
},
|
||||
}},
|
||||
})
|
||||
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
|
||||
if err := runConfigPluginsShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Plugins []struct {
|
||||
EmbeddedSkills *internalplatform.SkillsOverlayView `json:"embedded_skills"`
|
||||
} `json:"plugins"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(got.Plugins) != 1 {
|
||||
t.Fatalf("want 1 plugin, got %d", len(got.Plugins))
|
||||
}
|
||||
es := got.Plugins[0].EmbeddedSkills
|
||||
if es == nil {
|
||||
t.Fatalf("embedded_skills missing from rendered output:\n%s", out.String())
|
||||
}
|
||||
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
|
||||
len(es.Remove) != 1 || es.Remove[0] != "lark-a" ||
|
||||
!es.Overlay || !es.Base {
|
||||
t.Errorf("embedded_skills summary mismatch: %+v", es)
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin that did not customize embedded skills must not emit an
|
||||
// embedded_skills key, so the field's presence is a reliable signal that a fork
|
||||
// trimmed the tree.
|
||||
func TestConfigPluginsShow_OmitsEmbeddedSkillsWhenAbsent(t *testing.T) {
|
||||
internalplatform.SetActiveInventory(&internalplatform.Inventory{
|
||||
Plugins: []internalplatform.PluginEntry{{
|
||||
Name: "acme",
|
||||
Version: "1.0",
|
||||
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
|
||||
}},
|
||||
})
|
||||
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
|
||||
if err := runConfigPluginsShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("not json: %v", err)
|
||||
}
|
||||
plugins, ok := raw["plugins"].([]any)
|
||||
if !ok || len(plugins) != 1 {
|
||||
t.Fatalf("want 1 plugin in output, got: %s", out.String())
|
||||
}
|
||||
if _, ok := plugins[0].(map[string]any)["embedded_skills"]; ok {
|
||||
t.Errorf("embedded_skills must be omitted when the plugin customized no skills; got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func runConfigPolicyShow(f *cmdutil.Factory) error {
|
||||
out := map[string]any{
|
||||
"source": string(active.Source.Kind),
|
||||
"source_name": sourceName,
|
||||
"denied_paths": active.DeniedPaths,
|
||||
"denied_paths": active.DeniedPathCount(),
|
||||
}
|
||||
if len(active.Rules) > 0 {
|
||||
rules := make([]map[string]any, 0, len(active.Rules))
|
||||
|
||||
@@ -62,7 +62,10 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
||||
Kind: cmdpolicy.SourcePlugin,
|
||||
Name: "secaudit",
|
||||
},
|
||||
DeniedPaths: 42,
|
||||
DeniedByPath: map[string]cmdpolicy.Denial{
|
||||
"docs/create": {},
|
||||
"docs/update": {},
|
||||
},
|
||||
})
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
@@ -80,8 +83,8 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
||||
t.Errorf("source_name = %v, want secaudit", got["source_name"])
|
||||
}
|
||||
// json.Unmarshal returns float64 for numbers.
|
||||
if got["denied_paths"] != float64(42) {
|
||||
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
|
||||
if got["denied_paths"] != float64(2) {
|
||||
t.Errorf("denied_paths = %v, want 2", got["denied_paths"])
|
||||
}
|
||||
rulesAny, ok := got["rules"].([]any)
|
||||
if !ok || len(rulesAny) != 1 {
|
||||
|
||||
80
cmd/config/risk_control.go
Normal file
80
cmd/config/risk_control.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// 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),
|
||||
// This is persistent workspace policy, not credential management.
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
config, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
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"
|
||||
}
|
||||
130
cmd/config/risk_control_test.go
Normal file
130
cmd/config/risk_control_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -55,7 +56,14 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
||||
hint := recovery.Join("",
|
||||
recovery.Command(recovery.TargetProfileList, "run: lark-cli profile list")).
|
||||
WithFallback("select or configure an available profile through this distribution")
|
||||
return recovery.Annotate(
|
||||
errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").
|
||||
WithHint("%s", hint.String()),
|
||||
hint,
|
||||
)
|
||||
}
|
||||
users := "(no logged-in users)"
|
||||
if len(app.Users) > 0 {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
@@ -33,6 +34,17 @@ type DoctorOptions struct {
|
||||
|
||||
// NewCmdDoctor creates the doctor command.
|
||||
func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
return newCmdDoctor(f, nil)
|
||||
}
|
||||
|
||||
// NewCmdDoctorWithRecovery creates the doctor command with a build-local
|
||||
// recovery presenter. Distribution assembly uses this boundary; ordinary
|
||||
// callers keep NewCmdDoctor's original function signature and default output.
|
||||
func NewCmdDoctorWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
return newCmdDoctor(f, projector)
|
||||
}
|
||||
|
||||
func newCmdDoctor(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
opts := &DoctorOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -40,7 +52,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
Short: "CLI health check: config, auth, and connectivity",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
return doctorRun(opts)
|
||||
return doctorRun(opts, projector)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
@@ -74,13 +86,13 @@ func skip(name, msg string) checkResult {
|
||||
return checkResult{Name: name, Status: "skip", Message: msg}
|
||||
}
|
||||
|
||||
func doctorRun(opts *DoctorOptions) error {
|
||||
func doctorRun(opts *DoctorOptions, projector *recovery.Projector) error {
|
||||
f := opts.Factory
|
||||
var checks []checkResult
|
||||
|
||||
// ── 0. CLI version & update check ──
|
||||
checks = append(checks, pass("cli_version", build.Version))
|
||||
if !opts.Offline {
|
||||
if !opts.Offline && projector.CanReference(recovery.TargetUpdate) {
|
||||
checks = append(checks, checkCLIUpdate()...)
|
||||
}
|
||||
|
||||
@@ -96,7 +108,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
if errors.As(projector.Render(core.NotConfiguredError()), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
}
|
||||
@@ -110,7 +122,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
if err != nil {
|
||||
hint := ""
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
if errors.As(projector.Render(err), &cfgErr) {
|
||||
hint = cfgErr.Hint
|
||||
}
|
||||
checks = append(checks, fail("app_resolved", err.Error(), hint))
|
||||
@@ -121,7 +133,10 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
ep := core.ResolveEndpoints(cfg.Brand)
|
||||
|
||||
// ── 3. Identity readiness ──
|
||||
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
|
||||
diagnostics := identitydiag.FilterRecovery(
|
||||
identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline),
|
||||
projector.CanReference,
|
||||
)
|
||||
checks = append(checks,
|
||||
identityCheck("bot_identity", diagnostics.Bot),
|
||||
identityCheck("user_identity", diagnostics.User),
|
||||
@@ -215,7 +230,7 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
|
||||
// Unlike the root-level async check, this does a synchronous fetch with timeout
|
||||
// and works regardless of build version (dev builds included).
|
||||
func checkCLIUpdate() []checkResult {
|
||||
latest, err := update.FetchLatest()
|
||||
latest, err := fetchLatestForDoctor()
|
||||
if err != nil {
|
||||
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
|
||||
}
|
||||
@@ -228,6 +243,8 @@ func checkCLIUpdate() []checkResult {
|
||||
return []checkResult{pass("cli_update", latest+" (up to date)")}
|
||||
}
|
||||
|
||||
var fetchLatestForDoctor = update.FetchLatest
|
||||
|
||||
func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
|
||||
allOK := true
|
||||
for _, c := range checks {
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
)
|
||||
|
||||
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
||||
@@ -101,6 +103,31 @@ func TestNetworkChecks_Offline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorRunDoesNotFetchUpdateWhenCommandIsConcealed(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
oldFetch := fetchLatestForDoctor
|
||||
t.Cleanup(func() { fetchLatestForDoctor = oldFetch })
|
||||
|
||||
fetches := 0
|
||||
fetchLatestForDoctor = func() (string, error) {
|
||||
fetches++
|
||||
return "9.9.9", nil
|
||||
}
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandUpdate: surface.CommandConcealed,
|
||||
})
|
||||
projector := recovery.NewProjector(func() *surface.Plan { return plan })
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
_ = doctorRun(&DoctorOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
}, projector)
|
||||
if fetches != 0 {
|
||||
t.Fatalf("concealed update triggered %d npm fetch(es)", fetches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
@@ -124,7 +151,7 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Offline: true,
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("doctorRun() error = %v", err)
|
||||
}
|
||||
@@ -202,7 +229,7 @@ func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}, nil); err == nil {
|
||||
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
|
||||
}
|
||||
var got struct {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -15,11 +14,64 @@ import (
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// rootErrorPresenter owns the final command-facing error transformation for
|
||||
// one Cobra tree. Producers report typed facts and optional semantic recovery;
|
||||
// this boundary clones, completes, and projects them without exposing the
|
||||
// build-local surface plan to business packages.
|
||||
type rootErrorPresenter struct {
|
||||
f *cmdutil.Factory
|
||||
projector *recovery.Projector
|
||||
}
|
||||
|
||||
func newRootErrorPresenter(f *cmdutil.Factory, projector *recovery.Projector) *rootErrorPresenter {
|
||||
return &rootErrorPresenter{f: f, projector: projector}
|
||||
}
|
||||
|
||||
func (p *rootErrorPresenter) Present(err error) error {
|
||||
if err == nil || errs.IsRaw(err) {
|
||||
return err
|
||||
}
|
||||
rendered := p.projector.Render(err)
|
||||
p.completePermissionRecovery(rendered)
|
||||
applyNeedAuthorizationHint(p.f, rendered)
|
||||
return rendered
|
||||
}
|
||||
|
||||
// completePermissionRecovery supplies the canonical recovery for direct
|
||||
// PermissionError producers. API classification paths that already carry an
|
||||
// owned structured annotation keep their rendered Hint unchanged.
|
||||
func (p *rootErrorPresenter) completePermissionRecovery(err error) {
|
||||
typed, ok := errs.UnwrapTypedError(err)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
permissionErr, ok := typed.(*errs.PermissionError) //nolint:errorlint // presentation must not descend into the clone's original Cause
|
||||
if !ok || permissionErr.Hint != "" {
|
||||
return
|
||||
}
|
||||
identity := permissionErr.Identity
|
||||
if identity == "" && p.f != nil {
|
||||
identity = string(p.f.ResolvedIdentity)
|
||||
}
|
||||
if identity == "" {
|
||||
identity = string(core.AsUser)
|
||||
}
|
||||
hint := errclass.PermissionRecovery(
|
||||
permissionErr.MissingScopes,
|
||||
identity,
|
||||
permissionErr.Subtype,
|
||||
permissionErr.ConsoleURL,
|
||||
)
|
||||
permissionErr.Hint = p.projector.RenderHint(hint)
|
||||
}
|
||||
|
||||
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
|
||||
// "current command requires scope(s): X, Y" hint when the underlying error is
|
||||
// a need_user_authorization signal AND the current command declares scopes
|
||||
@@ -32,8 +84,12 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
||||
if !internalauth.IsNeedUserAuthorizationError(err) {
|
||||
return
|
||||
}
|
||||
var authErr *errs.AuthenticationError
|
||||
if !errors.As(err, &authErr) {
|
||||
typed, ok := errs.UnwrapTypedError(err)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
authErr, ok := typed.(*errs.AuthenticationError) //nolint:errorlint // enrich only the presented clone, never a nested producer Cause
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||
|
||||
139
cmd/error_presenter_test.go
Normal file
139
cmd/error_presenter_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRootErrorPresenterCompletesDirectPermissionRecoveryWithoutMutatingProducer(t *testing.T) {
|
||||
source := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope").
|
||||
WithMissingScopes("docx:document").
|
||||
WithIdentity("user")
|
||||
|
||||
visible := newRootErrorPresenter(
|
||||
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
|
||||
recovery.NewProjector(nil),
|
||||
).Present(source)
|
||||
visibleProblem, _ := errs.ProblemOf(visible)
|
||||
if !strings.Contains(visibleProblem.Hint, `auth login --scope "docx:document"`) {
|
||||
t.Fatalf("visible recovery = %q, want scoped auth login", visibleProblem.Hint)
|
||||
}
|
||||
if source.Hint != "" {
|
||||
t.Fatalf("presenter mutated producer hint: %q", source.Hint)
|
||||
}
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandAuthLogin: surface.CommandConcealed,
|
||||
})
|
||||
concealed := newRootErrorPresenter(
|
||||
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
|
||||
recovery.NewProjector(func() *surface.Plan { return plan }),
|
||||
).Present(source)
|
||||
concealedProblem, _ := errs.ProblemOf(concealed)
|
||||
if strings.Contains(concealedProblem.Hint, "auth login") ||
|
||||
!strings.Contains(concealedProblem.Hint, "supported authorization flow") {
|
||||
t.Fatalf("concealed recovery = %q, want target-free fallback", concealedProblem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootErrorPresenterDoesNotRecommendUserLoginForBotPermission(t *testing.T) {
|
||||
source := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope").
|
||||
WithMissingScopes("drive:file:download").
|
||||
WithIdentity("bot")
|
||||
|
||||
rendered := newRootErrorPresenter(
|
||||
&cmdutil.Factory{ResolvedIdentity: core.AsBot},
|
||||
recovery.NewProjector(nil),
|
||||
).Present(source)
|
||||
problem, _ := errs.ProblemOf(rendered)
|
||||
if strings.Contains(problem.Hint, "auth login") ||
|
||||
!strings.Contains(problem.Hint, "app developer") {
|
||||
t.Fatalf("bot recovery = %q", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootErrorPresenterDoesNotMutateNestedPermissionCause(t *testing.T) {
|
||||
inner := errs.NewPermissionError(errs.SubtypeMissingScope, "inner permission").
|
||||
WithMissingScopes("docx:document").
|
||||
WithIdentity("user")
|
||||
outer := errs.NewInternalError(errs.SubtypeUnknown, "outer failure").
|
||||
WithHint("retry the operation").
|
||||
WithCause(inner)
|
||||
|
||||
rendered := newRootErrorPresenter(
|
||||
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
|
||||
recovery.NewProjector(nil),
|
||||
).Present(outer)
|
||||
|
||||
if inner.Hint != "" {
|
||||
t.Fatalf("presenter mutated nested producer hint: %q", inner.Hint)
|
||||
}
|
||||
problem, _ := errs.ProblemOf(rendered)
|
||||
if got, want := problem.Hint, "retry the operation"; got != want {
|
||||
t.Fatalf("rendered outer hint = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootErrorPresenterDoesNotMutateNestedAuthenticationCause(t *testing.T) {
|
||||
f := factoryWithDeclaredServiceScope(t)
|
||||
source := internalauth.NewNeedUserAuthorizationError("ou_nested")
|
||||
var inner *errs.AuthenticationError
|
||||
if !errors.As(source, &inner) {
|
||||
t.Fatalf("source = %T, want nested *errs.AuthenticationError", source)
|
||||
}
|
||||
originalHint := inner.Hint
|
||||
outer := errs.NewInternalError(errs.SubtypeUnknown, "outer failure").
|
||||
WithHint("retry the operation").
|
||||
WithCause(source)
|
||||
|
||||
rendered := newRootErrorPresenter(f, recovery.NewProjector(nil)).Present(outer)
|
||||
|
||||
if got := inner.Hint; got != originalHint {
|
||||
t.Fatalf("presenter mutated nested authentication hint: got %q want %q", got, originalHint)
|
||||
}
|
||||
problem, _ := errs.ProblemOf(rendered)
|
||||
if got, want := problem.Hint, "retry the operation"; got != want {
|
||||
t.Fatalf("rendered outer hint = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func factoryWithDeclaredServiceScope(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
f := &cmdutil.Factory{ResolvedIdentity: core.AsUser}
|
||||
var target registry.CommandEntry
|
||||
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
||||
if len(entry.Scopes) > 0 {
|
||||
target = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if target.Command == "" {
|
||||
t.Fatal("failed to locate a service command with declared user scopes")
|
||||
}
|
||||
parts := strings.Split(target.Command, " ")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("service command = %q, want resource and method", target.Command)
|
||||
}
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
domain := &cobra.Command{Use: "calendar"}
|
||||
resource := &cobra.Command{Use: parts[0]}
|
||||
method := &cobra.Command{Use: parts[1]}
|
||||
root.AddCommand(domain)
|
||||
domain.AddCommand(resource)
|
||||
resource.AddCommand(method)
|
||||
f.CurrentCommand = method
|
||||
return f
|
||||
}
|
||||
@@ -278,27 +278,24 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
permissionErr := errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
|
||||
WithMissingScopes(missing...)
|
||||
if pf.identity.IsBot() {
|
||||
permissionErr.WithHint("%s", botScopeRemediationHint(pf.brand, pf.appID, missing))
|
||||
}
|
||||
return permissionErr
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
|
||||
// the tenant token carries them. User: the scan link only updates the app
|
||||
// manifest — the user's own token still lacks the scopes until it is
|
||||
// re-authorized — so direct the user to re-login instead.
|
||||
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
|
||||
if identity.IsBot() {
|
||||
return fmt.Sprintf("grant these scopes by scanning: %s",
|
||||
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
|
||||
strings.Join(missing, " "))
|
||||
// The bot-specific scan-to-enable link adds the scopes to the app manifest,
|
||||
// after which the tenant token carries them. User recovery is generated from
|
||||
// the PermissionError's identity and missing_scopes by the root presenter.
|
||||
func botScopeRemediationHint(brand core.LarkBrand, appID string, missing []string) string {
|
||||
return fmt.Sprintf("grant these scopes by scanning: %s",
|
||||
addonsHintURL(brand, appID, missingScopeAddons(core.AsBot, missing)))
|
||||
}
|
||||
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
|
||||
@@ -379,7 +376,7 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
if result == nil || result.Token == "" {
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"no tenant access token available for app %s", appID).
|
||||
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
|
||||
WithHint("check that app_secret is configured for this distribution")
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
|
||||
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
@@ -36,6 +38,8 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
@@ -90,6 +94,8 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
|
||||
@@ -264,18 +264,9 @@ func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
|
||||
// bot: scan-to-enable link (adds scopes to app manifest)
|
||||
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
|
||||
func TestBotScopeRemediationHintUsesScanLink(t *testing.T) {
|
||||
bot := botScopeRemediationHint(core.BrandFeishu, "cli_x", []string{"im:message"})
|
||||
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
|
||||
t.Errorf("bot hint should give the scan link, got: %s", bot)
|
||||
}
|
||||
// user: re-login (scan link cannot grant scopes to the user's own token)
|
||||
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
|
||||
if !strings.Contains(user, "auth login --scope") {
|
||||
t.Errorf("user hint should direct to auth login, got: %s", user)
|
||||
}
|
||||
if strings.Contains(user, "/page/launcher") {
|
||||
t.Errorf("user hint must NOT use the scan link, got: %s", user)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,29 @@ import (
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
type approvalSchemaJSONPayload struct {
|
||||
JQRootPath string `json:"jq_root_path"`
|
||||
AuthTypes []string `json:"auth_types"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Params []approvalSchemaJSONParam `json:"params"`
|
||||
ResolvedOutputSchema approvalSchemaJSONResolvedSchema `json:"resolved_output_schema"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONParam struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
SubscriptionKey bool `json:"subscription_key"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONResolvedSchema struct {
|
||||
Properties map[string]approvalSchemaJSONProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONProperty struct {
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
@@ -158,6 +181,60 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
scope string
|
||||
}{
|
||||
{"approval.instance.status_changed_v4", "approval:instance:read"},
|
||||
{"approval.task.status_changed_v4", "approval:task:read"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, tc.key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload approvalSchemaJSONPayload
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.JQRootPath != "." {
|
||||
t.Errorf("jq_root_path = %v, want .", payload.JQRootPath)
|
||||
}
|
||||
if got := payload.AuthTypes; !reflect.DeepEqual(got, []string{"user"}) {
|
||||
t.Errorf("auth_types = %#v, want user", got)
|
||||
}
|
||||
if got := payload.Scopes; !reflect.DeepEqual(got, []string{tc.scope}) {
|
||||
t.Errorf("scopes = %#v, want %s", got, tc.scope)
|
||||
}
|
||||
if len(payload.Params) != 1 {
|
||||
t.Fatalf("params = %#v, want one subscription_type param", payload.Params)
|
||||
}
|
||||
param := payload.Params[0]
|
||||
if param.Name != "subscription_type" || param.Type != "multi" || param.Required || param.SubscriptionKey {
|
||||
t.Fatalf("subscription_type param = %#v, want optional multi non-subscription-key param", param)
|
||||
}
|
||||
props := payload.ResolvedOutputSchema.Properties
|
||||
for _, field := range []string{"type", "event_id", "timestamp", "approval_code", "instance_code", "status", "operate_time"} {
|
||||
if _, ok := props[field]; !ok {
|
||||
t.Errorf("approval schema missing flat field %q: %+v", field, props)
|
||||
}
|
||||
}
|
||||
if _, ok := props["event"]; ok {
|
||||
t.Errorf("approval Custom schema should be flat, got envelope field event: %+v", props)
|
||||
}
|
||||
if got := props["operate_time"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("operate_time format = %v, want timestamp_ms", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
|
||||
67
cmd/flag_gate.go
Normal file
67
cmd/flag_gate.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
)
|
||||
|
||||
// globalFlagTargets maps each root persistent flag to the command capability
|
||||
// it belongs to. A new domain-tied global flag must add a row.
|
||||
var globalFlagTargets = map[string]surface.CommandID{
|
||||
"profile": surface.CommandProfile,
|
||||
}
|
||||
|
||||
// flagGateAnnotation distinguishes a surface-retired flag from one hidden
|
||||
// cosmetically (single-app mode force-shows the latter in root help).
|
||||
const flagGateAnnotation = "lark:surface_concealed_flag"
|
||||
|
||||
// applyPluginFlagGate hides and rejects global flags whose exact command
|
||||
// capability is absent from this build. It is called only by the explicit
|
||||
// distribution presentation pass.
|
||||
func applyPluginFlagGate(root *cobra.Command, plan *surface.Plan) {
|
||||
for flagName, target := range globalFlagTargets {
|
||||
if plan.CanReference(target) {
|
||||
continue
|
||||
}
|
||||
fl := root.PersistentFlags().Lookup(flagName)
|
||||
if fl == nil {
|
||||
continue
|
||||
}
|
||||
fl.Hidden = true
|
||||
if fl.Annotations == nil {
|
||||
fl.Annotations = map[string][]string{}
|
||||
}
|
||||
fl.Annotations[flagGateAnnotation] = []string{"true"}
|
||||
fl.Value = &gatedFlagValue{name: flagName, inner: fl.Value}
|
||||
}
|
||||
}
|
||||
|
||||
func isPolicyGatedFlag(fl *pflag.Flag) bool {
|
||||
return fl != nil && fl.Annotations[flagGateAnnotation] != nil
|
||||
}
|
||||
|
||||
// gatedFlagValue rejects at parse time, before cobra's help/version fast
|
||||
// paths (which never reach PersistentPreRunE). Its Set error carries
|
||||
// cobra's own unknown-flag wording so the root FlagErrorFunc classifies it
|
||||
// as an ordinary unknown flag without exposing policy state. Cobra may add
|
||||
// different parse context on root/group paths than on leaf commands.
|
||||
type gatedFlagValue struct {
|
||||
name string
|
||||
inner pflag.Value
|
||||
}
|
||||
|
||||
func (g *gatedFlagValue) String() string { return g.inner.String() }
|
||||
func (g *gatedFlagValue) Type() string { return g.inner.Type() }
|
||||
func (g *gatedFlagValue) Set(string) error {
|
||||
// Intermediate parse error, not a final envelope: pflag wraps it and
|
||||
// the root FlagErrorFunc (flagDidYouMean) converts it to the typed
|
||||
// unknown-flag validation error.
|
||||
return errors.New("unknown flag: --" + g.name) //nolint:forbidigo // intermediate parse error; flagDidYouMean emits the typed envelope
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
|
||||
got := composePendingNotice()
|
||||
got := composePendingNotice(nil)
|
||||
if got == nil {
|
||||
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func TestComposePendingNoticeEmpty(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
if got := composePendingNotice(); got != nil {
|
||||
if got := composePendingNotice(nil); got != nil {
|
||||
// update/skills pending are process-global; only assert the absence of
|
||||
// our own key to stay robust against unrelated pending state.
|
||||
if _, ok := got["deprecated_command"]; ok {
|
||||
|
||||
@@ -35,7 +35,10 @@ const userPolicyFileName = "policy.yml"
|
||||
//
|
||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||
// the InstallAll phase; nil/empty is fine.
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
||||
//
|
||||
// The returned denied map (nil when no rule denied anything) feeds the
|
||||
// optional, build-local distribution presentation pass in build.go.
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) (map[string]cmdpolicy.Denial, error) {
|
||||
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
|
||||
// yaml). When a plugin contributed rules we therefore do NOT even
|
||||
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
|
||||
@@ -65,7 +68,7 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
// show` reports "no policy" instead of a stale rule that
|
||||
// doesn't reflect the current command tree.
|
||||
cmdpolicy.SetActive(nil)
|
||||
return lerr
|
||||
return nil, lerr
|
||||
}
|
||||
yamlRules = loaded
|
||||
}
|
||||
@@ -77,11 +80,11 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
})
|
||||
if err != nil {
|
||||
cmdpolicy.SetActive(nil)
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// RuleName attributes a denial to a specific rule in the envelope.
|
||||
@@ -100,11 +103,12 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
cmdpolicy.Apply(rootCmd, denied)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rules: rules,
|
||||
Source: source,
|
||||
DeniedPaths: len(denied),
|
||||
Rules: rules,
|
||||
Source: source,
|
||||
DeniedByPath: denied,
|
||||
})
|
||||
return nil
|
||||
|
||||
return denied, nil
|
||||
}
|
||||
|
||||
// installPluginsAndHooks runs the InstallAll phase on the globally-
|
||||
@@ -156,7 +160,22 @@ func recordInventory(installResult *internalplatform.InstallResult) {
|
||||
AllowUnannotated: r.Rule.AllowUnannotated,
|
||||
})
|
||||
}
|
||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
|
||||
skillSrcs := make([]internalplatform.SkillsInventorySource, 0, len(installResult.PluginSkills))
|
||||
for _, ps := range installResult.PluginSkills {
|
||||
if ps.SkillsOverlay == nil {
|
||||
continue
|
||||
}
|
||||
skillSrcs = append(skillSrcs, internalplatform.SkillsInventorySource{
|
||||
PluginName: ps.PluginName,
|
||||
View: internalplatform.SkillsOverlayView{
|
||||
Allow: ps.SkillsOverlay.Allow,
|
||||
Remove: ps.SkillsOverlay.Remove,
|
||||
Overlay: ps.SkillsOverlay.Overlay != nil,
|
||||
Base: ps.SkillsOverlay.Base != nil,
|
||||
},
|
||||
})
|
||||
}
|
||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs, skillSrcs))
|
||||
}
|
||||
|
||||
// wireHooks installs Observer/Wrapper hooks onto every runnable command
|
||||
@@ -167,7 +186,20 @@ func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry)
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
hook.Install(rootCmd, reg, cobraCommandViewSource{})
|
||||
installHooks(rootCmd, reg)
|
||||
return emitStartup(ctx, reg)
|
||||
}
|
||||
|
||||
func installHooks(rootCmd *cobra.Command, reg *hook.Registry) {
|
||||
if reg != nil {
|
||||
hook.Install(rootCmd, reg, cobraCommandViewSource{})
|
||||
}
|
||||
}
|
||||
|
||||
func emitStartup(ctx context.Context, reg *hook.Registry) error {
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
return hook.Emit(ctx, reg, platform.Startup, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ max_risk: write
|
||||
`)
|
||||
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
if _, err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("apply policy: %v", err)
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
|
||||
tmpHome(t) // home set but no policy.yml written
|
||||
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
if _, err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("missing policy should not error, got %v", err)
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
|
||||
writePolicy(t, cfgDir, "::: not yaml :::")
|
||||
|
||||
root := fakeTree(t)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
_, err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("malformed yaml should produce an error")
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
||||
if _, err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
||||
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
|
||||
writePolicy(t, cfgDir, "max_risk: nukem\n")
|
||||
|
||||
root := fakeTree(t)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
_, err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid MaxRisk should produce an error")
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/skillpolicy"
|
||||
)
|
||||
|
||||
// installFatalGuard wires a fail-closed guard at every cobra dispatch
|
||||
@@ -110,6 +111,33 @@ func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginSkillErrorGuard surfaces a plugin SkillsOverlay configuration
|
||||
// error before any command runs. Two failure modes, split by reason code:
|
||||
//
|
||||
// - "invalid_skills_overlay" - a Remove/Overlay that cannot compose
|
||||
// - "multiple_skills_overlay_plugins" - two plugins each customizing skills
|
||||
//
|
||||
// The CLI must NOT silently fall back to default skills once an
|
||||
// integrator has declared a customization.
|
||||
func installPluginSkillErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() error {
|
||||
reasonCode := internalplatform.ReasonInvalidSkillsOverlay
|
||||
if errors.Is(err, skillpolicy.ErrMultipleSkillsOverlays) {
|
||||
reasonCode = internalplatform.ReasonMultipleSkillsOverlays
|
||||
}
|
||||
typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
|
||||
WithCause(err)
|
||||
if errors.Is(err, skillpolicy.ErrNoBaseSkillContent) {
|
||||
return typed.WithHint("this build embeds no base skill content; call cmd.SetEmbeddedSkillContent before Execute or provide a non-empty EmbeddedSkills.Base (reason_code %s)", reasonCode)
|
||||
}
|
||||
if errors.Is(err, skillpolicy.ErrInvalidHostBase) {
|
||||
return typed.WithHint("the wrapper's embedded base skill tree is invalid; fix the content passed to cmd.SetEmbeddedSkillContent (reason_code %s)", reasonCode)
|
||||
}
|
||||
return typed.WithHint("skill customization is broken (reason_code %s); fix the plugin's EmbeddedSkills configuration or remove the conflicting plugin", reasonCode)
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
||||
// failure as a typed validation error (failed_precondition). The hint's
|
||||
// reason code splits returned-error vs panic so consumers (audit /
|
||||
|
||||
326
cmd/presentation.go
Normal file
326
cmd/presentation.go
Normal file
@@ -0,0 +1,326 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
configcmd "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
)
|
||||
|
||||
const annotationUnavailableMessage = "lark:presentation_unavailable_message"
|
||||
|
||||
type projectedCommand struct {
|
||||
state surface.CommandState
|
||||
denial cmdpolicy.Denial
|
||||
}
|
||||
|
||||
// presentationProjection keeps one distribution's build-time presentation
|
||||
// state and denial provenance together, so a concealed command retains the
|
||||
// cause installed on its unavailable projection.
|
||||
type presentationProjection struct {
|
||||
commands map[surface.CommandID]projectedCommand
|
||||
}
|
||||
|
||||
func newPresentationProjection(denied map[string]cmdpolicy.Denial) *presentationProjection {
|
||||
projection := &presentationProjection{
|
||||
commands: make(map[surface.CommandID]projectedCommand, len(denied)),
|
||||
}
|
||||
for path, denial := range denied {
|
||||
projection.commands[surface.CommandID(path)] = projectedCommand{
|
||||
state: surface.CommandDeniedVisible,
|
||||
denial: denial,
|
||||
}
|
||||
}
|
||||
return projection
|
||||
}
|
||||
|
||||
func (p *presentationProjection) recordConcealed(path string, denial cmdpolicy.Denial) {
|
||||
p.commands[surface.CommandID(path)] = projectedCommand{
|
||||
state: surface.CommandConcealed,
|
||||
denial: denial,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *presentationProjection) denial(path string) (cmdpolicy.Denial, bool) {
|
||||
command, ok := p.commands[surface.CommandID(path)]
|
||||
if !ok || command.state != surface.CommandConcealed {
|
||||
return cmdpolicy.Denial{}, false
|
||||
}
|
||||
return command.denial, true
|
||||
}
|
||||
|
||||
func (p *presentationProjection) plan() *surface.Plan {
|
||||
states := make(map[surface.CommandID]surface.CommandState, len(p.commands))
|
||||
for id, command := range p.commands {
|
||||
states[id] = command.state
|
||||
}
|
||||
return surface.NewPlan(states)
|
||||
}
|
||||
|
||||
func (p *presentationProjection) hasConcealedCommands() bool {
|
||||
for _, command := range p.commands {
|
||||
if command.state == surface.CommandConcealed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// applyDistributionPresentation projects enforcement decisions onto the
|
||||
// command surface of this one build. Enforcement has already installed its
|
||||
// policy-rich deny stubs. Without an explicit presentation option, those stubs
|
||||
// and their legacy help/completion behavior are left untouched.
|
||||
func applyDistributionPresentation(
|
||||
root *cobra.Command,
|
||||
cfg restrictionPresentationConfig,
|
||||
denied map[string]cmdpolicy.Denial,
|
||||
) (*surface.Plan, bool) {
|
||||
projection := newPresentationProjection(denied)
|
||||
if !cfg.enabled {
|
||||
return projection.plan(), false
|
||||
}
|
||||
|
||||
collectPluginConcealments(root, denied, projection)
|
||||
if cfg.hidePolicyDiagnostics {
|
||||
collectDiagnosticConcealments(root, projection)
|
||||
}
|
||||
propagateConcealedPureGroups(root, projection)
|
||||
|
||||
installUnavailableProjections(root, projection, cfg.effectiveUnavailableMessage())
|
||||
|
||||
plan := projection.plan()
|
||||
applyPresentationAffordances(root, plan)
|
||||
return plan, projection.hasConcealedCommands()
|
||||
}
|
||||
|
||||
func collectPluginConcealments(
|
||||
root *cobra.Command,
|
||||
denied map[string]cmdpolicy.Denial,
|
||||
projection *presentationProjection,
|
||||
) {
|
||||
for path, denial := range denied {
|
||||
if !cmdpolicy.IsPluginPolicySource(denial.PolicySource) {
|
||||
continue
|
||||
}
|
||||
cmd := findByPath(root, path)
|
||||
if cmd == nil || commandDenialLayer(cmd) == cmdpolicy.LayerStrictMode {
|
||||
continue
|
||||
}
|
||||
projection.recordConcealed(path, denial)
|
||||
}
|
||||
}
|
||||
|
||||
func collectDiagnosticConcealments(
|
||||
root *cobra.Command,
|
||||
projection *presentationProjection,
|
||||
) {
|
||||
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||
if findByPath(root, path) == nil {
|
||||
continue
|
||||
}
|
||||
projection.recordConcealed(path, cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: "distribution:presentation",
|
||||
ReasonCode: "diagnostics_concealed",
|
||||
Reason: "policy diagnostics concealed by the distribution",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func propagateConcealedPureGroups(
|
||||
root *cobra.Command,
|
||||
projection *presentationProjection,
|
||||
) {
|
||||
// A pure parent becomes absent only when every live child is absent. Repeat
|
||||
// bottom-up until all newly-empty intermediate groups converge.
|
||||
for {
|
||||
changed := false
|
||||
plan := projection.plan()
|
||||
walkCommandsPostOrder(root, func(cmd *cobra.Command) {
|
||||
path, denial, ok := concealedPureGroup(cmd, plan, projection)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projection.recordConcealed(path, denial)
|
||||
changed = true
|
||||
})
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func concealedPureGroup(
|
||||
cmd *cobra.Command,
|
||||
plan *surface.Plan,
|
||||
projection *presentationProjection,
|
||||
) (string, cmdpolicy.Denial, bool) {
|
||||
path := cmdpolicy.CanonicalPath(cmd)
|
||||
if !cmd.HasParent() || !isPresentationPureGroup(cmd) ||
|
||||
plan.IsConcealed(surface.CommandID(path)) {
|
||||
return "", cmdpolicy.Denial{}, false
|
||||
}
|
||||
children := cmd.Commands()
|
||||
if len(children) == 0 {
|
||||
return "", cmdpolicy.Denial{}, false
|
||||
}
|
||||
|
||||
var cause cmdpolicy.Denial
|
||||
for _, child := range children {
|
||||
childPath := cmdpolicy.CanonicalPath(child)
|
||||
if !plan.IsConcealed(surface.CommandID(childPath)) {
|
||||
return "", cmdpolicy.Denial{}, false
|
||||
}
|
||||
if denial, ok := projection.denial(childPath); ok && cause.Layer == "" {
|
||||
cause = denial
|
||||
}
|
||||
}
|
||||
if cause.Layer == "" {
|
||||
cause = cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: "distribution:presentation",
|
||||
ReasonCode: "all_children_concealed",
|
||||
Reason: "all child commands are concealed",
|
||||
}
|
||||
}
|
||||
return path, cause, true
|
||||
}
|
||||
|
||||
func installUnavailableProjections(
|
||||
root *cobra.Command,
|
||||
projection *presentationProjection,
|
||||
message string,
|
||||
) {
|
||||
for id, command := range projection.commands {
|
||||
if command.state != surface.CommandConcealed {
|
||||
continue
|
||||
}
|
||||
path := string(id)
|
||||
if cmd := findByPath(root, path); cmd != nil {
|
||||
installUnavailableProjection(cmd, path, command.denial, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyPresentationAffordances(root *cobra.Command, plan *surface.Plan) {
|
||||
applyPluginFlagGate(root, plan)
|
||||
configcmd.ProjectInitHelp(
|
||||
findByPath(root, string(surface.CommandConfigInit)),
|
||||
plan.CanReference(surface.CommandConfigBind),
|
||||
)
|
||||
root.Long = renderRootHelpSections(rootLongSections, plan)
|
||||
root.SetUsageTemplate(renderRootUsageTemplate(plan))
|
||||
}
|
||||
|
||||
func commandDenialLayer(cmd *cobra.Command) string {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return ""
|
||||
}
|
||||
return cmd.Annotations[cmdpolicy.AnnotationDenialLayer]
|
||||
}
|
||||
|
||||
func isPresentationPureGroup(cmd *cobra.Command) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
return (cmd.Run == nil && cmd.RunE == nil) || cmdpolicy.IsPureGroup(cmd)
|
||||
}
|
||||
|
||||
func walkCommandsPostOrder(cmd *cobra.Command, visit func(*cobra.Command)) {
|
||||
for _, child := range cmd.Commands() {
|
||||
walkCommandsPostOrder(child, visit)
|
||||
}
|
||||
visit(cmd)
|
||||
}
|
||||
|
||||
// installUnavailableProjection changes presentation only. It preserves the
|
||||
// enforcement denial as the in-process cause when one exists, while the wire
|
||||
// intentionally exposes no policy source, rule name, or reason code.
|
||||
func installUnavailableProjection(cmd *cobra.Command, path string, denial cmdpolicy.Denial, message string) {
|
||||
cmd.Hidden = true
|
||||
cmd.DisableFlagParsing = true
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
cmd.PersistentPreRunE = func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
}
|
||||
cmd.PersistentPreRun = nil
|
||||
cmd.PreRunE = nil
|
||||
cmd.PreRun = nil
|
||||
|
||||
hideFlags := func(flags *pflag.FlagSet) {
|
||||
flags.VisitAll(func(flag *pflag.Flag) {
|
||||
flag.Hidden = true
|
||||
})
|
||||
}
|
||||
// Hide only flags owned by this command. cmd.Flags() may contain inherited
|
||||
// flag pointers after Cobra merges sets; mutating those would hide a global
|
||||
// flag from unrelated commands.
|
||||
hideFlags(cmd.LocalNonPersistentFlags())
|
||||
hideFlags(cmd.PersistentFlags())
|
||||
cmd.ValidArgs = nil
|
||||
cmd.ValidArgsFunction = func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[annotationUnavailableMessage] = message
|
||||
if cmd.Annotations[cmdpolicy.AnnotationDenialLayer] == "" {
|
||||
cmd.Annotations[cmdpolicy.AnnotationDenialLayer] = denial.Layer
|
||||
cmd.Annotations[cmdpolicy.AnnotationDenialSource] = denial.PolicySource
|
||||
}
|
||||
|
||||
cmd.RunE = func(*cobra.Command, []string) error {
|
||||
err := errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", message)
|
||||
if denial.Layer != "" {
|
||||
err.WithCause(cmdpolicy.CommandDeniedFromDenial(path, denial))
|
||||
}
|
||||
return err
|
||||
}
|
||||
cmd.Run = nil
|
||||
}
|
||||
|
||||
// unavailableHelpMessage is deliberately keyed only by the opt-in projection
|
||||
// annotation. A legacy Restrict denial carries enforcement annotations but
|
||||
// continues to use Cobra's stock explicit-help behavior.
|
||||
func unavailableHelpMessage(cmd *cobra.Command) (string, bool) {
|
||||
for current := cmd; current != nil; current = current.Parent() {
|
||||
if current.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if message := current.Annotations[annotationUnavailableMessage]; message != "" {
|
||||
return message, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// findByPath resolves a canonical slash path (for example
|
||||
// "config/policy/show") to a command node.
|
||||
func findByPath(root *cobra.Command, path string) *cobra.Command {
|
||||
cur := root
|
||||
for _, segment := range strings.Split(path, "/") {
|
||||
var next *cobra.Command
|
||||
for _, child := range cur.Commands() {
|
||||
if child.Name() == segment {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
72
cmd/presentation_options.go
Normal file
72
cmd/presentation_options.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
// defaultRestrictedCommandUnavailableMessage is the distribution-neutral
|
||||
// fallback for a concealed command. It lives in the presentation layer rather
|
||||
// than extension/platform.Rule: the same enforcement rule may be rendered as a
|
||||
// visible policy denial by one host and as an absent capability by another.
|
||||
const defaultRestrictedCommandUnavailableMessage = "command not included in this build"
|
||||
|
||||
// restrictionPresentationConfig is a per-Build snapshot. It is deliberately
|
||||
// private so adding a future presentation knob cannot break downstream
|
||||
// unkeyed struct literals.
|
||||
type restrictionPresentationConfig struct {
|
||||
enabled bool
|
||||
unavailableMessage string
|
||||
hidePolicyDiagnostics bool
|
||||
}
|
||||
|
||||
func (c restrictionPresentationConfig) effectiveUnavailableMessage() string {
|
||||
if c.unavailableMessage != "" {
|
||||
return c.unavailableMessage
|
||||
}
|
||||
return defaultRestrictedCommandUnavailableMessage
|
||||
}
|
||||
|
||||
// RestrictionPresentationOption configures the presentation of commands
|
||||
// denied by an embedded distribution's Restrict plugin.
|
||||
//
|
||||
// Values are accepted only by ConcealRestrictedCommands. The pointed-to
|
||||
// configuration type is private by design; callers use the constructors in
|
||||
// this file instead of depending on a public struct layout.
|
||||
type RestrictionPresentationOption func(*restrictionPresentationConfig)
|
||||
|
||||
// ConcealRestrictedCommands opts one command tree into presenting
|
||||
// plugin-restricted commands as capabilities absent from the distribution.
|
||||
//
|
||||
// Restrict remains the enforcement boundary. Without this BuildOption,
|
||||
// existing Restrict plugins keep their established failed_precondition
|
||||
// envelope, explicit-help, and completion behavior.
|
||||
//
|
||||
// Pass the returned option to Build, or to ExecuteWithOptions when using the
|
||||
// standard host entrypoint.
|
||||
func ConcealRestrictedCommands(opts ...RestrictionPresentationOption) BuildOption {
|
||||
presentation := restrictionPresentationConfig{enabled: true}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(&presentation)
|
||||
}
|
||||
}
|
||||
return func(cfg *buildConfig) {
|
||||
cfg.presentation = presentation
|
||||
}
|
||||
}
|
||||
|
||||
// UnavailableMessage customizes the error message for a concealed command.
|
||||
// An empty message selects the distribution-neutral default.
|
||||
func UnavailableMessage(message string) RestrictionPresentationOption {
|
||||
return func(cfg *restrictionPresentationConfig) {
|
||||
cfg.unavailableMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
// HidePolicyDiagnostics removes the policy self-inspection commands from a
|
||||
// concealed distribution. Without it, those commands remain the operator's
|
||||
// recovery and inspection escape hatch.
|
||||
func HidePolicyDiagnostics() RestrictionPresentationOption {
|
||||
return func(cfg *restrictionPresentationConfig) {
|
||||
cfg.hidePolicyDiagnostics = true
|
||||
}
|
||||
}
|
||||
73
cmd/presentation_options_test.go
Normal file
73
cmd/presentation_options_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
// Preserve callers that store the original entrypoint as a function value.
|
||||
// Making Execute variadic would compile at ordinary call sites but break this
|
||||
// established source contract.
|
||||
var _ func() int = Execute
|
||||
|
||||
func TestConcealRestrictedCommandsDefaults(t *testing.T) {
|
||||
cfg := &buildConfig{}
|
||||
ConcealRestrictedCommands()(cfg)
|
||||
|
||||
if !cfg.presentation.enabled {
|
||||
t.Fatal("concealment must be explicitly enabled by the BuildOption")
|
||||
}
|
||||
if cfg.presentation.hidePolicyDiagnostics {
|
||||
t.Fatal("policy diagnostics must remain available by default")
|
||||
}
|
||||
if got := cfg.presentation.effectiveUnavailableMessage(); got != defaultRestrictedCommandUnavailableMessage {
|
||||
t.Errorf("message = %q, want %q", got, defaultRestrictedCommandUnavailableMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcealRestrictedCommandsOptions(t *testing.T) {
|
||||
cfg := &buildConfig{}
|
||||
ConcealRestrictedCommands(
|
||||
UnavailableMessage("not part of acme-cli"),
|
||||
HidePolicyDiagnostics(),
|
||||
)(cfg)
|
||||
|
||||
if !cfg.presentation.enabled {
|
||||
t.Fatal("concealment must be enabled")
|
||||
}
|
||||
if !cfg.presentation.hidePolicyDiagnostics {
|
||||
t.Fatal("HidePolicyDiagnostics option was not applied")
|
||||
}
|
||||
if got := cfg.presentation.effectiveUnavailableMessage(); got != "not part of acme-cli" {
|
||||
t.Errorf("message = %q, want custom message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcealRestrictedCommandsIsBuildLocal(t *testing.T) {
|
||||
concealed := &buildConfig{}
|
||||
ordinary := &buildConfig{}
|
||||
|
||||
ConcealRestrictedCommands(
|
||||
UnavailableMessage("acme only"),
|
||||
HidePolicyDiagnostics(),
|
||||
)(concealed)
|
||||
|
||||
if ordinary.presentation.enabled {
|
||||
t.Fatal("applying an option to one build must not enable another")
|
||||
}
|
||||
if ordinary.presentation.hidePolicyDiagnostics {
|
||||
t.Fatal("applying an option to one build must not mutate another")
|
||||
}
|
||||
if got := ordinary.presentation.effectiveUnavailableMessage(); got != defaultRestrictedCommandUnavailableMessage {
|
||||
t.Errorf("ordinary message = %q, want default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableMessageEmptyUsesDefault(t *testing.T) {
|
||||
cfg := &buildConfig{}
|
||||
ConcealRestrictedCommands(UnavailableMessage(""))(cfg)
|
||||
|
||||
if got := cfg.presentation.effectiveUnavailableMessage(); got != defaultRestrictedCommandUnavailableMessage {
|
||||
t.Errorf("message = %q, want %q", got, defaultRestrictedCommandUnavailableMessage)
|
||||
}
|
||||
}
|
||||
757
cmd/presentation_test.go
Normal file
757
cmd/presentation_test.go
Normal file
@@ -0,0 +1,757 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
func registerRestriction(t *testing.T, deny []string, configure func(*platform.Builder) *platform.Builder) {
|
||||
t.Helper()
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
builder := platform.NewPlugin("acme", "1.0").
|
||||
Restrict(&platform.Rule{Deny: deny})
|
||||
if configure != nil {
|
||||
builder = configure(builder)
|
||||
}
|
||||
platform.Register(builder.MustBuild())
|
||||
}
|
||||
|
||||
func TestBuildInternalRestrictDefaultPreservesLegacyContract(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"skills/read"}, nil)
|
||||
|
||||
runtime, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
leaf := findByPath(root, "skills/read")
|
||||
if leaf == nil {
|
||||
t.Fatal("skills/read not found")
|
||||
}
|
||||
if got := runtime.surface.State(surface.CommandSkillsRead); got != surface.CommandDeniedVisible {
|
||||
t.Fatalf("surface state = %v, want denied-visible", got)
|
||||
}
|
||||
if _, projected := unavailableHelpMessage(leaf); projected {
|
||||
t.Fatal("legacy Restrict unexpectedly received concealment presentation")
|
||||
}
|
||||
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("RunE error = %T %v, want ValidationError", err, err)
|
||||
}
|
||||
if validation.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", validation.Subtype)
|
||||
}
|
||||
if !strings.Contains(validation.Hint, "source plugin:acme") ||
|
||||
!strings.Contains(validation.Hint, "reason_code") {
|
||||
t.Errorf("legacy policy metadata missing from hint: %q", validation.Hint)
|
||||
}
|
||||
if flag := leaf.Flags().Lookup("json"); flag == nil || flag.Hidden {
|
||||
t.Errorf("legacy Restrict must preserve local flag presentation; flag=%+v", flag)
|
||||
}
|
||||
|
||||
var help bytes.Buffer
|
||||
root.SetOut(&help)
|
||||
root.SetErr(&help)
|
||||
if err := root.Help(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, title := range []string{"Lark domains:", "Agent tooling:", "CLI management:"} {
|
||||
if !strings.Contains(help.String(), title) {
|
||||
t.Errorf("default root help lost group %q", title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInternalConcealmentIsExplicitAndKeepsDenialAsCause(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"skills/read"}, nil)
|
||||
|
||||
runtime, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(UnavailableMessage("not shipped by acme")),
|
||||
)
|
||||
leaf := findByPath(root, "skills/read")
|
||||
if got := runtime.surface.State(surface.CommandSkillsRead); got != surface.CommandConcealed {
|
||||
t.Fatalf("surface state = %v, want concealed", got)
|
||||
}
|
||||
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("RunE error = %T %v, want ValidationError", err, err)
|
||||
}
|
||||
if validation.Subtype != errs.SubtypeCommandUnavailable ||
|
||||
validation.Message != "not shipped by acme" || validation.Hint != "" {
|
||||
t.Errorf("concealed error = %+v", validation)
|
||||
}
|
||||
var denied *platform.CommandDeniedError
|
||||
if !errors.As(err, &denied) || denied.Path != "skills/read" ||
|
||||
denied.PolicySource != "plugin:acme" {
|
||||
t.Errorf("enforcement cause not preserved: %T %+v", err, denied)
|
||||
}
|
||||
|
||||
if flag := leaf.Flags().Lookup("json"); flag == nil || !flag.Hidden {
|
||||
t.Errorf("concealed command must hide owned flags; flag=%+v", flag)
|
||||
}
|
||||
if flag := root.PersistentFlags().Lookup("profile"); flag == nil || flag.Hidden {
|
||||
t.Errorf("concealing a leaf must not mutate inherited global flags; flag=%+v", flag)
|
||||
}
|
||||
if args, _ := leaf.ValidArgsFunction(leaf, nil, ""); len(args) != 0 {
|
||||
t.Errorf("concealed command completed positionals: %v", args)
|
||||
}
|
||||
|
||||
help := findByPath(root, "help")
|
||||
if help == nil || help.RunE == nil {
|
||||
t.Fatal("concealment-specific help command not installed")
|
||||
}
|
||||
err = help.RunE(help, []string{"skills", "read"})
|
||||
if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("help on concealed command = %v, want command_unavailable", err)
|
||||
}
|
||||
|
||||
active := cmdpolicy.GetActive()
|
||||
if active == nil || active.DeniedByPath["skills/read"].PolicySource != "plugin:acme" {
|
||||
t.Fatalf("presentation overwrote enforcement snapshot: %+v", active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionPresentationNeverConcealsYAMLPolicy(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
leaf := &cobra.Command{Use: "probe", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
root.AddCommand(leaf)
|
||||
denial := cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: "yaml:/tmp/policy.yml",
|
||||
ReasonCode: "command_denylisted",
|
||||
Reason: "denied by user policy",
|
||||
}
|
||||
denied := map[string]cmdpolicy.Denial{"probe": denial}
|
||||
cmdpolicy.Apply(root, denied)
|
||||
|
||||
plan, concealed := applyDistributionPresentation(
|
||||
root,
|
||||
restrictionPresentationConfig{enabled: true},
|
||||
denied,
|
||||
)
|
||||
if concealed {
|
||||
t.Fatal("user-owned YAML denial must not be projected as absent")
|
||||
}
|
||||
if got := plan.State("probe"); got != surface.CommandDeniedVisible {
|
||||
t.Fatalf("surface state = %v, want denied-visible", got)
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("YAML denial changed by distribution presentation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootGroupsFollowSurfaceConcealmentNotLegacyHiddenState(t *testing.T) {
|
||||
newRoot := func() *cobra.Command {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
child := &cobra.Command{
|
||||
Use: "skills",
|
||||
GroupID: groupTooling,
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
root.AddCommand(child)
|
||||
return root
|
||||
}
|
||||
|
||||
yamlRoot := newRoot()
|
||||
yamlChild := findByPath(yamlRoot, "skills")
|
||||
yamlChild.Hidden = true
|
||||
finalizeRootCommandGroups(yamlRoot, surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandSkills: surface.CommandDeniedVisible,
|
||||
}))
|
||||
if len(yamlRoot.Groups()) != 1 || yamlRoot.Groups()[0].ID != groupTooling {
|
||||
t.Fatalf("legacy/YAML hidden command removed its group: %+v", yamlRoot.Groups())
|
||||
}
|
||||
|
||||
concealedRoot := newRoot()
|
||||
finalizeRootCommandGroups(concealedRoot, surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandSkills: surface.CommandConcealed,
|
||||
}))
|
||||
if len(concealedRoot.Groups()) != 0 {
|
||||
t.Fatalf("concealed-only group remained visible: %+v", concealedRoot.Groups())
|
||||
}
|
||||
if got := findByPath(concealedRoot, "skills").GroupID; got != "" {
|
||||
t.Fatalf("concealed child retained undefined GroupID %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentationDropsRootSkillsFooterWithSkillsRead(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.SetUsageTemplate(rootUsageTemplate)
|
||||
applyPresentationAffordances(root, surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandSkillsRead: surface.CommandConcealed,
|
||||
}))
|
||||
if strings.Contains(root.UsageTemplate(), "Skills setup (one-time, humans)") {
|
||||
t.Fatalf("concealed skills/read left the root skills footer:\n%s", root.UsageTemplate())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentationProjectsEveryFrameworkOwnedRootHelpTarget(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli", Long: rootLong}
|
||||
root.SetUsageTemplate(rootUsageTemplate)
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
rootHelpAPI: surface.CommandConcealed,
|
||||
surface.CommandSchema: surface.CommandConcealed,
|
||||
rootHelpCalendarAgenda: surface.CommandConcealed,
|
||||
rootHelpMailList: surface.CommandConcealed,
|
||||
})
|
||||
|
||||
applyPresentationAffordances(root, plan)
|
||||
|
||||
for _, dead := range []string{
|
||||
"lark-cli api ",
|
||||
"lark-cli schema ",
|
||||
"lark-cli calendar +agenda",
|
||||
"lark-cli mail user_mailbox.messages list",
|
||||
} {
|
||||
if strings.Contains(root.Long, dead) || strings.Contains(root.UsageTemplate(), dead) {
|
||||
t.Errorf("concealed root-help target %q survived:\nLong:\n%s\nTemplate:\n%s",
|
||||
dead, root.Long, root.UsageTemplate())
|
||||
}
|
||||
}
|
||||
if !strings.Contains(root.Long, "Browse commands:") ||
|
||||
!strings.Contains(root.UsageTemplate(), "lark-cli <command>") {
|
||||
t.Fatalf("target-independent root guidance was removed:\nLong:\n%s\nTemplate:\n%s",
|
||||
root.Long, root.UsageTemplate())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkOwnedRootHelpTargetsExistInDefaultTree(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
_, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
WithoutPlugins(),
|
||||
)
|
||||
var fragments []rootHelpFragment
|
||||
for _, section := range rootLongSections {
|
||||
fragments = append(fragments, section.fragments...)
|
||||
}
|
||||
fragments = append(fragments, rootUsageSynopsis...)
|
||||
for _, fragment := range fragments {
|
||||
if fragment.target == "" {
|
||||
continue
|
||||
}
|
||||
if command := findByPath(root, string(fragment.target)); command == nil {
|
||||
t.Errorf("root-help target %q does not resolve in the default command tree", fragment.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentationKeepsDefaultRootHelpByteStable(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli", Long: rootLong}
|
||||
root.SetUsageTemplate(rootUsageTemplate)
|
||||
wantLong, wantUsage := root.Long, root.UsageTemplate()
|
||||
|
||||
applyPresentationAffordances(root, nil)
|
||||
|
||||
if root.Long != wantLong {
|
||||
t.Fatalf("default root Long changed:\nwant:\n%s\n\ngot:\n%s", wantLong, root.Long)
|
||||
}
|
||||
if root.UsageTemplate() != wantUsage {
|
||||
t.Fatalf("default root usage template changed:\nwant:\n%s\n\ngot:\n%s", wantUsage, root.UsageTemplate())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpRejectsDescendantOfConcealedParent(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
parent := &cobra.Command{Use: "apps"}
|
||||
child := &cobra.Command{Use: "+db-execute", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
parent.AddCommand(child)
|
||||
root.AddCommand(parent)
|
||||
installUnavailableProjection(parent, "apps", cmdpolicy.Denial{}, "not shipped")
|
||||
installHelpCommand(root)
|
||||
|
||||
help := findByPath(root, "help")
|
||||
if help == nil || help.RunE == nil {
|
||||
t.Fatal("help command not installed")
|
||||
}
|
||||
err := help.RunE(help, []string{"apps", "+db-execute"})
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) ||
|
||||
validation.Subtype != errs.SubtypeCommandUnavailable ||
|
||||
validation.Message != "not shipped" {
|
||||
t.Fatalf("help descendant error = %#v, want command_unavailable inherited from parent", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHidePolicyDiagnosticsIsHostPresentationOnly(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"config/**"}, nil)
|
||||
|
||||
runtime, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(HidePolicyDiagnostics()),
|
||||
)
|
||||
for _, path := range []string{
|
||||
"config",
|
||||
"config/policy",
|
||||
"config/policy/show",
|
||||
"config/plugins",
|
||||
"config/plugins/show",
|
||||
} {
|
||||
cmd := findByPath(root, path)
|
||||
if cmd == nil || cmd.RunE == nil {
|
||||
t.Fatalf("%s missing unavailable projection", path)
|
||||
}
|
||||
err := cmd.RunE(cmd, nil)
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) ||
|
||||
validation.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("%s error = %v, want command_unavailable", path, err)
|
||||
}
|
||||
if !runtime.surface.IsConcealed(surface.CommandID(path)) {
|
||||
t.Errorf("%s not recorded in build-local surface", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Synthetic presentation decisions must not be reported as policy facts.
|
||||
active := cmdpolicy.GetActive()
|
||||
if active == nil {
|
||||
t.Fatal("missing active enforcement policy")
|
||||
}
|
||||
if _, exists := active.DeniedByPath["config/policy/show"]; exists {
|
||||
t.Errorf("presentation-only diagnostic concealment leaked into ActivePolicy: %+v", active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcealedBuildOmitsEmptyRootGroup(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{
|
||||
"auth", "auth/**",
|
||||
"config", "config/**",
|
||||
"profile", "profile/**",
|
||||
"doctor",
|
||||
"update",
|
||||
}, nil)
|
||||
|
||||
_, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(HidePolicyDiagnostics()),
|
||||
)
|
||||
var help bytes.Buffer
|
||||
root.SetOut(&help)
|
||||
root.SetErr(&help)
|
||||
if err := root.Help(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(help.String(), "CLI management:") {
|
||||
t.Errorf("empty management group leaked into help:\n%s", help.String())
|
||||
}
|
||||
if !strings.Contains(help.String(), "Agent tooling:") {
|
||||
t.Errorf("non-empty tooling group disappeared:\n%s", help.String())
|
||||
}
|
||||
|
||||
// Cobra's Execute path validates GroupID definitions before parsing flags.
|
||||
// Calling root.Help directly does not exercise this invariant.
|
||||
help.Reset()
|
||||
root.SetOut(&help)
|
||||
root.SetErr(&help)
|
||||
root.SetArgs([]string{"--help"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("concealed root Execute --help: %v", err)
|
||||
}
|
||||
if strings.Contains(help.String(), "CLI management:") {
|
||||
t.Errorf("empty management group leaked through Execute:\n%s", help.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryRenderingUsesExactBuildLocalSurfaceAndDoesNotMutate(t *testing.T) {
|
||||
tmpHome(t)
|
||||
previousWorkspace := core.CurrentWorkspace()
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(previousWorkspace) })
|
||||
|
||||
registerRestriction(t, []string{"config/init"}, nil)
|
||||
concealedRuntime, _, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(),
|
||||
)
|
||||
|
||||
platform.ResetForTesting()
|
||||
defaultRuntime, _, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
WithoutPlugins(),
|
||||
)
|
||||
|
||||
if concealedRuntime.surface.CanReference(surface.CommandConfigInit) {
|
||||
t.Fatal("config/init should be concealed")
|
||||
}
|
||||
if !concealedRuntime.surface.CanReference(surface.CommandConfigStrictMode) {
|
||||
t.Fatal("exact leaf concealment incorrectly removed config/strict-mode")
|
||||
}
|
||||
|
||||
original := core.NotConfiguredError()
|
||||
originalProblem, ok := errs.ProblemOf(original)
|
||||
if !ok || originalProblem.Hint == "" {
|
||||
t.Fatalf("invalid test error: %v", original)
|
||||
}
|
||||
wantHint := originalProblem.Hint
|
||||
|
||||
concealed := concealedRuntime.recovery.Render(original)
|
||||
concealedProblem, _ := errs.ProblemOf(concealed)
|
||||
if strings.Contains(concealedProblem.Hint, "config init") ||
|
||||
!strings.Contains(concealedProblem.Hint, "configure this distribution") {
|
||||
t.Errorf("concealed tree did not use target-free recovery fallback: %q", concealedProblem.Hint)
|
||||
}
|
||||
if originalProblem.Hint != wantHint {
|
||||
t.Fatalf("rendering mutated source hint: %q -> %q", wantHint, originalProblem.Hint)
|
||||
}
|
||||
|
||||
visible := defaultRuntime.recovery.Render(original)
|
||||
visibleProblem, _ := errs.ProblemOf(visible)
|
||||
if visibleProblem.Hint != wantHint {
|
||||
t.Errorf("default tree lost recovery after second Build: %q", visibleProblem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentBuildsKeepIndependentSurfacePlans(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"config/init"}, nil)
|
||||
inv := buildInvocationForTest(t)
|
||||
|
||||
const pairs = 4
|
||||
type result struct {
|
||||
concealed bool
|
||||
state surface.CommandState
|
||||
}
|
||||
results := make(chan result, pairs*2)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < pairs; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
runtime, _, _ := buildInternal(
|
||||
context.Background(),
|
||||
inv,
|
||||
ConcealRestrictedCommands(),
|
||||
)
|
||||
results <- result{concealed: true, state: runtime.surface.State(surface.CommandConfigInit)}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
runtime, _, _ := buildInternal(
|
||||
context.Background(),
|
||||
inv,
|
||||
WithoutPlugins(),
|
||||
)
|
||||
results <- result{state: runtime.surface.State(surface.CommandConfigInit)}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
for got := range results {
|
||||
want := surface.CommandAvailable
|
||||
if got.concealed {
|
||||
want = surface.CommandConcealed
|
||||
}
|
||||
if got.state != want {
|
||||
t.Errorf("concealed=%v state=%v, want %v", got.concealed, got.state, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAffordancesDisappearWithoutDroppingIndependentRecovery(t *testing.T) {
|
||||
update.SetPending(&update.UpdateInfo{Current: "1.0.0", Latest: "2.0.0"})
|
||||
skillscheck.SetPending(&skillscheck.StaleNotice{Current: "1.0.0", Target: "2.0.0"})
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+read",
|
||||
Replacement: "+cells-get",
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
update.SetPending(nil)
|
||||
skillscheck.SetPending(nil)
|
||||
deprecation.SetPending(nil)
|
||||
})
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandUpdate: surface.CommandConcealed,
|
||||
})
|
||||
got := composePendingNotice(plan)
|
||||
if got == nil {
|
||||
t.Fatal("independent deprecation recovery was dropped")
|
||||
}
|
||||
if _, exists := got["update"]; exists {
|
||||
t.Errorf("update notice survived concealed update: %+v", got)
|
||||
}
|
||||
if _, exists := got["skills"]; exists {
|
||||
t.Errorf("skills drift notice survived concealed update: %+v", got)
|
||||
}
|
||||
entry, ok := got["deprecated_command"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing deprecated_command: %+v", got)
|
||||
}
|
||||
if entry["replacement"] != "+cells-get" || entry["skill"] != "lark-sheets" {
|
||||
t.Errorf("independent deprecation fields lost: %+v", entry)
|
||||
}
|
||||
if _, exists := entry["action"]; exists {
|
||||
t.Errorf("unavailable update action survived: %+v", entry)
|
||||
}
|
||||
if strings.Contains(entry["message"].(string), "lark-cli update") {
|
||||
t.Errorf("dead update pointer survived in message: %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupNoticesDoesNoProviderWorkWhenUpdateIsConcealed(t *testing.T) {
|
||||
oldCheck, oldRefresh, oldSkills := checkCachedUpdate, refreshUpdateCache, initializeSkillsCheck
|
||||
oldPending := output.PendingNotice
|
||||
t.Cleanup(func() {
|
||||
checkCachedUpdate, refreshUpdateCache, initializeSkillsCheck = oldCheck, oldRefresh, oldSkills
|
||||
output.PendingNotice = oldPending
|
||||
})
|
||||
|
||||
var checks, refreshes, skillChecks int
|
||||
checkCachedUpdate = func(string) *update.UpdateInfo {
|
||||
checks++
|
||||
return nil
|
||||
}
|
||||
refreshUpdateCache = func(string) { refreshes++ }
|
||||
initializeSkillsCheck = func(string) { skillChecks++ }
|
||||
|
||||
setupNotices(surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandUpdate: surface.CommandConcealed,
|
||||
}))
|
||||
if checks != 0 || refreshes != 0 || skillChecks != 0 {
|
||||
t.Fatalf("concealed update performed provider work: cache=%d refresh=%d skills=%d",
|
||||
checks, refreshes, skillChecks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteProfileBootstrapPreservesDefaultAndDefersOnlyForOptIn(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
|
||||
|
||||
t.Run("default remains plain exit one", func(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
code, stdout, stderr := executeWithCapturedOS(t, nil, "--profile")
|
||||
if code != 1 || stdout != "" ||
|
||||
stderr != "Error: flag needs an argument: --profile\n" {
|
||||
t.Fatalf("default --profile: exit=%d stdout=%q stderr=%q", code, stdout, stderr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("opt-in concealed profile is an unknown flag", func(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"profile", "profile/**"}, nil)
|
||||
|
||||
code, _, stderr := executeWithCapturedOS(
|
||||
t,
|
||||
[]BuildOption{ConcealRestrictedCommands()},
|
||||
"--profile",
|
||||
)
|
||||
if code != 2 ||
|
||||
!strings.Contains(stderr, `"subtype": "invalid_argument"`) ||
|
||||
!strings.Contains(stderr, `unknown flag \"--profile\"`) {
|
||||
t.Fatalf("concealed --profile: exit=%d stderr=%s", code, stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteWithOptionsAppliesEachBuildOptionOnce(t *testing.T) {
|
||||
tmpHome(t)
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
var applied int
|
||||
option := BuildOption(func(*buildConfig) { applied++ })
|
||||
code, _, stderr := executeWithCapturedOS(t, []BuildOption{option}, "--version")
|
||||
if code != 0 {
|
||||
t.Fatalf("--version exit=%d stderr=%s", code, stderr)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Fatalf("BuildOption applied %d times, want exactly once", applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcealmentHelpIsOutsideBusinessHooks(t *testing.T) {
|
||||
tmpHome(t)
|
||||
var observed, wrapped int
|
||||
registerRestriction(t, []string{"skills/read"}, func(builder *platform.Builder) *platform.Builder {
|
||||
return builder.
|
||||
Observer(platform.Before, "observe", platform.All(), func(context.Context, platform.Invocation) {
|
||||
observed++
|
||||
}).
|
||||
Wrap("wrap", platform.All(), func(next platform.Handler) platform.Handler {
|
||||
return func(ctx context.Context, inv platform.Invocation) error {
|
||||
wrapped++
|
||||
return next(ctx, inv)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
_, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(),
|
||||
)
|
||||
help := findByPath(root, "help")
|
||||
err := help.RunE(help, []string{"skills", "read"})
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) ||
|
||||
validation.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Fatalf("help error = %v", err)
|
||||
}
|
||||
if observed != 0 || wrapped != 0 {
|
||||
t.Fatalf("help entered business hooks: observed=%d wrapped=%d", observed, wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapperCannotSwallowConcealedCommandEnforcement(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"skills/read"}, func(builder *platform.Builder) *platform.Builder {
|
||||
return builder.Wrap("swallow", platform.All(), func(platform.Handler) platform.Handler {
|
||||
return func(context.Context, platform.Invocation) error { return nil }
|
||||
})
|
||||
})
|
||||
|
||||
_, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(),
|
||||
)
|
||||
leaf := findByPath(root, "skills/read")
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) ||
|
||||
validation.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Fatalf("wrapper swallowed denial: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcealedCommandLeavesFlagAndPositionalCompletion(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"skills/read"}, nil)
|
||||
_, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(),
|
||||
)
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"__complete", "skills", "read", "--"},
|
||||
{"__complete", "skills", "read", ""},
|
||||
} {
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs(args)
|
||||
_ = root.Execute()
|
||||
if strings.Contains(out.String(), "--json") || strings.Contains(out.String(), "lark-") {
|
||||
t.Errorf("%v exposed concealed completion:\n%s", args, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyStrictStubWinsOverPluginDenial(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
stub := findCmd(root, "auth", "login")
|
||||
if stub == nil {
|
||||
t.Fatal("auth/login strict stub missing")
|
||||
}
|
||||
|
||||
cmdpolicy.Apply(root, map[string]cmdpolicy.Denial{
|
||||
"auth/login": {
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: "plugin:acme",
|
||||
},
|
||||
})
|
||||
if got := stub.Annotations[cmdpolicy.AnnotationDenialLayer]; got != cmdpolicy.LayerStrictMode {
|
||||
t.Fatalf("denial layer = %q, want strict_mode", got)
|
||||
}
|
||||
err := stub.RunE(stub, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "strict mode") {
|
||||
t.Errorf("double-restricted command lost strict-mode error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func executeWithCapturedOS(
|
||||
t *testing.T,
|
||||
opts []BuildOption,
|
||||
args ...string,
|
||||
) (int, string, string) {
|
||||
t.Helper()
|
||||
oldArgs, oldStdout, oldStderr := os.Args, os.Stdout, os.Stderr
|
||||
stdout, err := os.CreateTemp(t.TempDir(), "stdout")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stderr, err := os.CreateTemp(t.TempDir(), "stderr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored := false
|
||||
restore := func() {
|
||||
if restored {
|
||||
return
|
||||
}
|
||||
restored = true
|
||||
os.Args, os.Stdout, os.Stderr = oldArgs, oldStdout, oldStderr
|
||||
}
|
||||
defer restore()
|
||||
|
||||
os.Args = append([]string{"e2e-cli"}, args...)
|
||||
os.Stdout, os.Stderr = stdout, stderr
|
||||
code := ExecuteWithOptions(opts...)
|
||||
restore()
|
||||
|
||||
if err := stdout.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := stderr.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdoutData, err := os.ReadFile(stdout.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stderrData, err := os.ReadFile(stderr.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return code, string(stdoutData), string(stderrData)
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -181,6 +183,47 @@ func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRemoveRun_AddRecoveryUsesBuildLocalSurface(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "only",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "only",
|
||||
AppId: "app-only",
|
||||
AppSecret: core.PlainSecret("secret-only"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
source := profileRemoveRun(nil, "only")
|
||||
var original *errs.ValidationError
|
||||
if !errors.As(source, &original) {
|
||||
t.Fatalf("profileRemoveRun() error = %T, want *errs.ValidationError", source)
|
||||
}
|
||||
const visibleHint = "add another profile first: lark-cli profile add"
|
||||
if original.Hint != visibleHint {
|
||||
t.Fatalf("producer hint = %q, want %q", original.Hint, visibleHint)
|
||||
}
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandProfileAdd: surface.CommandConcealed,
|
||||
})
|
||||
var concealed *errs.ValidationError
|
||||
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
|
||||
t.Fatalf("rendered error = %T, want *errs.ValidationError", rendered)
|
||||
}
|
||||
const fallback = "configure another profile through this distribution before removing the only profile"
|
||||
if concealed.Hint != fallback {
|
||||
t.Errorf("concealed hint = %q, want %q", concealed.Hint, fallback)
|
||||
}
|
||||
if original.Hint != visibleHint {
|
||||
t.Errorf("concealed render mutated producer hint: %q", original.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
)
|
||||
|
||||
// NewCmdProfileRemove creates the profile remove subcommand.
|
||||
@@ -45,8 +46,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
}
|
||||
|
||||
if len(multi.Apps) == 1 {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile").
|
||||
WithHint("add another profile first: lark-cli profile add")
|
||||
return recovery.Attach(
|
||||
errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile"),
|
||||
recovery.Join("",
|
||||
recovery.Command(recovery.TargetProfileAdd, "add another profile first: lark-cli profile add"),
|
||||
).WithFallback("configure another profile through this distribution before removing the only profile"),
|
||||
)
|
||||
}
|
||||
|
||||
app := &multi.Apps[idx]
|
||||
|
||||
14
cmd/prune.go
14
cmd/prune.go
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
)
|
||||
|
||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||
@@ -105,9 +106,16 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
},
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
|
||||
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
|
||||
WithCause(cd)
|
||||
hint := recovery.Join("; ",
|
||||
recovery.Text(fmt.Sprintf("denied by %s policy (reason_code %s)", cd.Layer, cd.ReasonCode)),
|
||||
recovery.Command(recovery.TargetConfigStrictMode, stubHint),
|
||||
)
|
||||
return recovery.Annotate(
|
||||
errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
|
||||
WithHint("%s", hint.String()).
|
||||
WithCause(cd),
|
||||
hint,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -379,3 +381,48 @@ func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
|
||||
t.Errorf("denial annotation overwritten or missing")
|
||||
}
|
||||
}
|
||||
|
||||
// The strict-mode stub carries a targeted config/strict-mode action alongside
|
||||
// non-command policy context. Rendering for a concealed tree drops only the
|
||||
// dead pointer and does not mutate the source error.
|
||||
func TestStrictModeStub_ConfigHintUsesBuildLocalSurface(t *testing.T) {
|
||||
child := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
stub := strictModeStubFrom(child, core.StrictModeBot)
|
||||
source := stub.RunE(stub, nil)
|
||||
var original *errs.ValidationError
|
||||
if !errors.As(source, &original) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", source, source)
|
||||
}
|
||||
if original.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %q, want failed_precondition", original.Subtype)
|
||||
}
|
||||
if !strings.Contains(original.Hint, "config strict-mode") {
|
||||
t.Fatalf("producer hint = %q, want config strict-mode", original.Hint)
|
||||
}
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandConfigStrictMode: surface.CommandConcealed,
|
||||
})
|
||||
var concealed *errs.ValidationError
|
||||
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
|
||||
t.Fatalf("rendered error = %T, want *errs.ValidationError", rendered)
|
||||
}
|
||||
if concealed == original {
|
||||
t.Fatal("Render must clone the typed error")
|
||||
}
|
||||
if strings.Contains(concealed.Hint, "config strict-mode") {
|
||||
t.Errorf("concealed hint still contains config strict-mode: %q", concealed.Hint)
|
||||
}
|
||||
if !strings.Contains(concealed.Hint, "reason_code identity_not_supported") {
|
||||
t.Errorf("non-command policy guidance was lost: %q", concealed.Hint)
|
||||
}
|
||||
|
||||
var visible *errs.ValidationError
|
||||
if !errors.As(recovery.Render(source, nil), &visible) ||
|
||||
!strings.Contains(visible.Hint, "config strict-mode") {
|
||||
t.Errorf("visible render must keep config strict-mode, got %+v", visible)
|
||||
}
|
||||
if !strings.Contains(original.Hint, "config strict-mode") {
|
||||
t.Errorf("concealed render mutated source hint: %q", original.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
386
cmd/root.go
386
cmd/root.go
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -21,70 +22,16 @@ import (
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/skillref"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
|
||||
AGENT QUICKSTART (driving this as an agent? start here):
|
||||
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
|
||||
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
|
||||
Prefer a +shortcut over the raw API resource when one matches the task.
|
||||
Risk: each command's --help shows read | write | high-risk-write;
|
||||
high-risk-write needs --yes, only after the user confirms.
|
||||
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
|
||||
|
||||
EXAMPLES (one per command style, in order of preference):
|
||||
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
|
||||
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
|
||||
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
|
||||
|
||||
// rootUsageTemplate is cobra's default usage template with two root-only
|
||||
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
|
||||
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
|
||||
// footer. Subcommands render the stock template unchanged. The rest is verbatim
|
||||
// cobra so the command groups and flags are untouched.
|
||||
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
|
||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]{{end}}{{else}}Usage:
|
||||
lark-cli <command> [subcommand] [method] [flags]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
|
||||
|
||||
Aliases:
|
||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
|
||||
Examples:
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
||||
|
||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
|
||||
|
||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||
|
||||
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
|
||||
Flags:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
Global Flags:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
|
||||
|
||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
|
||||
`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
||||
@@ -94,25 +41,69 @@ Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://
|
||||
var rawInvocationArgs []string
|
||||
|
||||
func Execute() int {
|
||||
return executeWithOptions(nil)
|
||||
}
|
||||
|
||||
// ExecuteWithOptions is the standard entrypoint for wrapper distributions that
|
||||
// need host-level Build options such as ConcealRestrictedCommands. Execute
|
||||
// intentionally keeps its original non-variadic signature for source
|
||||
// compatibility with callers that store it as a func() int value.
|
||||
func ExecuteWithOptions(opts ...BuildOption) int {
|
||||
return executeWithOptions(opts)
|
||||
}
|
||||
|
||||
func executeWithOptions(opts []BuildOption) int {
|
||||
rawInvocationArgs = os.Args[1:]
|
||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
inv, bootstrapErr := BootstrapInvocationContext(os.Args[1:])
|
||||
cfg := &buildConfig{}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(cfg)
|
||||
}
|
||||
}
|
||||
deferProfileError := cfg.presentation.enabled &&
|
||||
isDeferredBootstrapProfileError(bootstrapErr)
|
||||
if bootstrapErr != nil && !deferProfileError {
|
||||
fmt.Fprintln(os.Stderr, "Error:", bootstrapErr)
|
||||
return 1
|
||||
}
|
||||
if cfg.streams == nil {
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr)(cfg)
|
||||
}
|
||||
if !cfg.hideProfileSet {
|
||||
HideProfile(isSingleAppMode())(cfg)
|
||||
}
|
||||
if !cfg.startupBrandSet {
|
||||
WithStartupBrand(ResolveStartupBrand(inv.Profile))(cfg)
|
||||
}
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
ctx := context.Background()
|
||||
f, rootCmd, reg := buildInternal(
|
||||
ctx, inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
|
||||
)
|
||||
if deferProfileError {
|
||||
cfg.deferStartup = true
|
||||
}
|
||||
runtime, rootCmd, reg := buildInternalWithConfig(ctx, inv, cfg)
|
||||
f := runtime.Factory
|
||||
|
||||
if deferProfileError {
|
||||
if runtime.surface.CanReference(surface.CommandProfile) {
|
||||
// The completed distribution still ships --profile. Replay the
|
||||
// exact pre-Build legacy failure and do not emit Startup, notices,
|
||||
// or Shutdown for an invocation that never passed bootstrap.
|
||||
fmt.Fprintln(os.Stderr, "Error:", bootstrapErr)
|
||||
return 1
|
||||
}
|
||||
if reg != nil {
|
||||
if err := emitStartup(ctx, reg); err != nil {
|
||||
installPluginLifecycleErrorGuard(rootCmd, err)
|
||||
reg = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notices (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
setupNotices()
|
||||
setupNotices(runtime.surface)
|
||||
}
|
||||
|
||||
runErr := rootCmd.Execute()
|
||||
@@ -126,69 +117,98 @@ func Execute() int {
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
return handleRootError(f, runErr)
|
||||
return handleRootError(f, runErr, runtime.recovery)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// isDeferredBootstrapProfileError identifies the one bootstrap parse failure
|
||||
// an explicitly concealed distribution may need the completed tree to render.
|
||||
// Default and legacy builds never defer it.
|
||||
func isDeferredBootstrapProfileError(err error) bool {
|
||||
return err != nil && err.Error() == "flag needs an argument: --profile"
|
||||
}
|
||||
|
||||
// Notice provider seams keep the "concealed update means no cache, network, or
|
||||
// skills-state access" contract directly testable. Production always uses the
|
||||
// concrete implementations below.
|
||||
var (
|
||||
checkCachedUpdate = update.CheckCached
|
||||
refreshUpdateCache = update.RefreshCache
|
||||
initializeSkillsCheck = skillscheck.Init
|
||||
)
|
||||
|
||||
// setupNotices wires both the binary update notice and the skills
|
||||
// staleness notice into output.PendingNotice as a composed function.
|
||||
// Each provider populates an independent key under _notice; either
|
||||
// or both may be present in any given envelope.
|
||||
func setupNotices() {
|
||||
// Binary update — synchronous cache check + async refresh
|
||||
if info := update.CheckCached(build.Version); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
ver := build.Version
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
|
||||
func setupNotices(plan *surface.Plan) {
|
||||
if plan.CanReference(surface.CommandUpdate) {
|
||||
// Binary update — synchronous cache check + async refresh.
|
||||
if info := checkCachedUpdate(build.Version); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
ver := build.Version
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
|
||||
}
|
||||
}()
|
||||
refreshUpdateCache(ver)
|
||||
if update.GetPending() == nil {
|
||||
if info := checkCachedUpdate(ver); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
}
|
||||
}()
|
||||
update.RefreshCache(ver)
|
||||
if update.GetPending() == nil {
|
||||
if info := update.CheckCached(ver); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Skills check — synchronous, local-only (no network, no goroutine).
|
||||
skillscheck.Init(build.Version)
|
||||
// Skills drift has only one recovery action: lark-cli update. Do not
|
||||
// even inspect local drift state when that action is absent.
|
||||
initializeSkillsCheck(build.Version)
|
||||
}
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = composePendingNotice
|
||||
// Capture this build's immutable plan; never consult another Build's state.
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
return composePendingNotice(plan)
|
||||
}
|
||||
}
|
||||
|
||||
// composePendingNotice merges all process-level pending notices (available
|
||||
// update, skills/binary drift, deprecated-command alias) into the map surfaced
|
||||
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
|
||||
// Extracted from Execute so the composition is unit-testable.
|
||||
func composePendingNotice() map[string]interface{} {
|
||||
func composePendingNotice(plan *surface.Plan) map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
canUpdate := plan.CanReference(surface.CommandUpdate)
|
||||
// Update and skills-drift notices have no recovery path of their own:
|
||||
// both exist solely to steer the caller to `lark-cli update`.
|
||||
if canUpdate {
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
}
|
||||
if dep := deprecation.GetPending(); dep != nil {
|
||||
entry := map[string]interface{}{
|
||||
"command": dep.Command,
|
||||
"message": dep.Message(),
|
||||
"action": "lark-cli update",
|
||||
"message": dep.MessageWithoutUpdateAction(),
|
||||
}
|
||||
if canUpdate {
|
||||
entry["message"] = dep.Message()
|
||||
entry["action"] = "lark-cli update"
|
||||
}
|
||||
if dep.Replacement != "" {
|
||||
entry["replacement"] = dep.Replacement
|
||||
@@ -245,15 +265,22 @@ func configureFlagCompletions(args []string) {
|
||||
// argument validation): typed as an invalid_argument envelope (exit 2),
|
||||
// matching the explicit flag/subcommand guards. Flag parse errors are
|
||||
// already typed upstream by the root FlagErrorFunc.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
func handleRootError(
|
||||
f *cmdutil.Factory,
|
||||
err error,
|
||||
projector *recovery.Projector,
|
||||
) int {
|
||||
errOut := f.IOStreams.ErrOut
|
||||
renderedErr := err
|
||||
|
||||
// When the typed error is a need_user_authorization signal, fold in the
|
||||
// current command's declared scopes as a Hint so the user/AI sees the
|
||||
// concrete scope(s) to re-auth with. The hint is computed on the fly from
|
||||
// local shortcut/service metadata — it never depends on server state.
|
||||
// local shortcut/service metadata. Both semantic recovery filtering and
|
||||
// dynamic enrichment operate on a concrete clone, never the producer's
|
||||
// reusable error value.
|
||||
if !errs.IsRaw(err) {
|
||||
applyNeedAuthorizationHint(f, err)
|
||||
renderedErr = newRootErrorPresenter(f, projector).Present(err)
|
||||
}
|
||||
|
||||
// Staged dispatch: capture the typed exit code BEFORE attempting the
|
||||
@@ -264,7 +291,7 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
// WriteTypedErrorEnvelope still returns false when err carries no
|
||||
// Problem; in that case we fall through to the signal / plain-text paths.
|
||||
typedExit := output.ExitCodeOf(err)
|
||||
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
|
||||
if output.WriteTypedErrorEnvelope(errOut, renderedErr, string(f.ResolvedIdentity)) {
|
||||
return typedExit
|
||||
}
|
||||
|
||||
@@ -557,15 +584,10 @@ const (
|
||||
groupManagement = "cli-management"
|
||||
)
|
||||
|
||||
// groupRootCommands classifies root's direct children into the help groups,
|
||||
// called once after all commands are registered. Unclassified commands fall to
|
||||
// cobra's "Additional Commands" section.
|
||||
func groupRootCommands(root *cobra.Command) {
|
||||
root.AddGroup(
|
||||
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
|
||||
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
|
||||
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
|
||||
)
|
||||
// classifyRootCommands assigns root children to help groups after registration.
|
||||
// Group definitions are attached separately, after optional distribution
|
||||
// projection, so a concealed build can omit a now-empty heading.
|
||||
func classifyRootCommands(root *cobra.Command) {
|
||||
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
|
||||
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
|
||||
for _, c := range root.Commands() {
|
||||
@@ -583,6 +605,46 @@ func groupRootCommands(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeRootCommandGroups attaches Cobra group definitions once. A group is
|
||||
// omitted only when this build's surface plan concealed all its children.
|
||||
// Hidden legacy/YAML commands remain referenceable and therefore keep the
|
||||
// historical (possibly empty) heading.
|
||||
func finalizeRootCommandGroups(root *cobra.Command, plan *surface.Plan) {
|
||||
if root == nil || len(root.Groups()) != 0 {
|
||||
return
|
||||
}
|
||||
groups := []*cobra.Group{
|
||||
{ID: groupDomains, Title: "Lark domains:"},
|
||||
{ID: groupTooling, Title: "Agent tooling:"},
|
||||
{ID: groupManagement, Title: "CLI management:"},
|
||||
}
|
||||
for _, group := range groups {
|
||||
if plan != nil && !rootGroupHasReferenceableChild(root, group.ID, plan) {
|
||||
// Cobra validates that every non-empty child GroupID has a
|
||||
// matching definition before dispatch, including hidden children.
|
||||
// If presentation removes an entire group, clear those now-hidden
|
||||
// assignments as well as omitting the heading.
|
||||
for _, child := range root.Commands() {
|
||||
if child.GroupID == group.ID {
|
||||
child.GroupID = ""
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
root.AddGroup(group)
|
||||
}
|
||||
}
|
||||
|
||||
func rootGroupHasReferenceableChild(root *cobra.Command, groupID string, plan *surface.Plan) bool {
|
||||
for _, child := range root.Commands() {
|
||||
if child.GroupID == groupID &&
|
||||
plan.CanReference(surface.CommandID(cmdpolicy.CanonicalPath(child))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
|
||||
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
|
||||
func isLarkDomain(c *cobra.Command) bool {
|
||||
@@ -601,6 +663,15 @@ func isLarkDomain(c *cobra.Command) bool {
|
||||
func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagName(ferr)
|
||||
if !isUnknown {
|
||||
// A policy-gated flag invoked bare ("flag needs an argument")
|
||||
// never reaches its rejecting Value; it still presents as
|
||||
// unregistered, exactly like a set one.
|
||||
if gated, ok := gatedFlagFromNeedsArg(c, ferr); ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+gated, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + gated, Reason: "unknown flag"}).
|
||||
WithHint("run `%s --help` to see valid flags", c.CommandPath())
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
}
|
||||
@@ -623,6 +694,25 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
WithHint("%s", hint)
|
||||
}
|
||||
|
||||
// gatedFlagFromNeedsArg reports whether ferr is pflag's "flag needs an
|
||||
// argument: --name" for a policy-gated flag on this command's flag set.
|
||||
func gatedFlagFromNeedsArg(c *cobra.Command, ferr error) (string, bool) {
|
||||
const p = "flag needs an argument: --"
|
||||
msg := ferr.Error()
|
||||
i := strings.Index(msg, p)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
name := msg[i+len(p):]
|
||||
if j := strings.IndexAny(name, " \t"); j >= 0 {
|
||||
name = name[:j]
|
||||
}
|
||||
if fl := c.Root().PersistentFlags().Lookup(name); isPolicyGatedFlag(fl) {
|
||||
return name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
|
||||
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
|
||||
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
|
||||
@@ -659,16 +749,59 @@ func visibleFlagNames(c *cobra.Command) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// installHelpCommand upgrades Cobra's default help command so that
|
||||
// `lark-cli help <plugin-restricted-cmd>` returns a typed error (exit 2)
|
||||
// instead of printing an envelope and exiting 0 — cobra's stock help
|
||||
// command has no error channel.
|
||||
func installHelpCommand(root *cobra.Command) {
|
||||
root.InitDefaultHelpCmd()
|
||||
helpCmd := findByPath(root, "help")
|
||||
if helpCmd == nil {
|
||||
return
|
||||
}
|
||||
helpCmd.Run = nil
|
||||
helpCmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
target, _, err := root.Find(args)
|
||||
if err != nil || target == nil {
|
||||
c.Printf("Unknown help topic %#q\n", args)
|
||||
return root.Usage()
|
||||
}
|
||||
if msg, ok := unavailableHelpMessage(target); ok {
|
||||
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg)
|
||||
}
|
||||
target.SetContext(c.Context())
|
||||
target.InitDefaultHelpFlag()
|
||||
target.InitDefaultVersionFlag()
|
||||
return target.Help()
|
||||
}
|
||||
// help attaches after policy evaluation (framework meta command, never
|
||||
// policy-evaluated). No risk annotation: it would render a "Risk:"
|
||||
// line that stock cobra help output does not carry.
|
||||
cmdutil.DisableAuthCheck(helpCmd)
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
||||
// flags that are normally hidden in single-app mode (currently --profile)
|
||||
// when rendering the root command's own help, so users discovering the CLI
|
||||
// still see them at `lark-cli --help`.
|
||||
func installTipsHelpFunc(root *cobra.Command) {
|
||||
//
|
||||
// skillContent is read lazily at help-render time (not captured up front) so
|
||||
// the domain-guide pointer reflects the resolved skill tree -- the same
|
||||
// f.SkillContent that `skills list`/`read` serve -- even though plugin skill
|
||||
// customization is applied after this help func is installed.
|
||||
func installTipsHelpFunc(
|
||||
root *cobra.Command,
|
||||
skillContent func() fs.FS,
|
||||
skillReferences func() *skillref.Resolver,
|
||||
projector *recovery.Projector,
|
||||
) {
|
||||
defaultHelp := root.HelpFunc()
|
||||
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd == root {
|
||||
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
|
||||
// Force-show flags hidden by single-app mode; never a
|
||||
// policy-retired one.
|
||||
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden && !isPolicyGatedFlag(f) {
|
||||
f.Hidden = false
|
||||
defer func() { f.Hidden = true }()
|
||||
}
|
||||
@@ -676,15 +809,22 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
// Domain and method commands compose their agent guidance into Long lazily
|
||||
// here (shortcuts attach after service registration); both skip the generic
|
||||
// bottom-of-help append below.
|
||||
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
|
||||
var refs *skillref.Resolver
|
||||
if skillReferences != nil {
|
||||
refs = skillReferences()
|
||||
}
|
||||
content := skillContent()
|
||||
if service.PrepareDomainHelpWithReferences(cmd, content, refs) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
|
||||
if service.PrepareMethodHelpWithProjection(cmd, content, refs, func() bool {
|
||||
return projector.CanReference(recovery.TargetSchema)
|
||||
}) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
|
||||
if service.PrepareShortcutHelpWithReferences(cmd, content, refs) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
|
||||
154
cmd/root_help.go
Normal file
154
cmd/root_help.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
)
|
||||
|
||||
// rootHelpFragment is one framework-owned root-help fragment. A fragment with
|
||||
// a target is emitted only while that exact command remains referenceable in
|
||||
// this build. Keeping the target next to the text prevents curated examples
|
||||
// from becoming dead pointers in reduced distributions.
|
||||
type rootHelpFragment struct {
|
||||
target surface.CommandID
|
||||
text string
|
||||
}
|
||||
|
||||
// rootHelpSection keeps a heading coupled to the target-aware entries it
|
||||
// introduces. When projection removes every entry, the heading disappears
|
||||
// with them instead of leaving an empty section in reduced builds.
|
||||
type rootHelpSection struct {
|
||||
heading string
|
||||
fragments []rootHelpFragment
|
||||
}
|
||||
|
||||
const (
|
||||
rootHelpAPI surface.CommandID = "api"
|
||||
rootHelpCalendarAgenda surface.CommandID = "calendar/+agenda"
|
||||
rootHelpMailList surface.CommandID = "mail/user_mailbox.messages/list"
|
||||
)
|
||||
|
||||
var rootLongSections = []rootHelpSection{
|
||||
{fragments: []rootHelpFragment{
|
||||
{text: `lark-cli — Lark/Feishu CLI tool.
|
||||
|
||||
AGENT QUICKSTART (driving this as an agent? start here):
|
||||
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources`},
|
||||
{target: surface.CommandSchema, text: `
|
||||
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples`},
|
||||
{text: `
|
||||
Prefer a +shortcut over the raw API resource when one matches the task.
|
||||
Risk: each command's --help shows read | write | high-risk-write;
|
||||
high-risk-write needs --yes, only after the user confirms.
|
||||
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).`},
|
||||
}},
|
||||
{
|
||||
heading: "\n\nEXAMPLES (one per command style, in order of preference):",
|
||||
fragments: []rootHelpFragment{
|
||||
{target: rootHelpCalendarAgenda, text: `
|
||||
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these`},
|
||||
{target: rootHelpMailList, text: `
|
||||
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method`},
|
||||
{target: surface.CommandSchema, text: `
|
||||
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling`},
|
||||
{target: rootHelpAPI, text: `
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// rootLong is the fully-visible default text retained as a compatibility
|
||||
// oracle. Reduced builds derive their text from the same typed fragments.
|
||||
var rootLong = renderRootHelpSections(rootLongSections, nil)
|
||||
|
||||
func renderRootHelpSections(sections []rootHelpSection, plan *surface.Plan) string {
|
||||
var b strings.Builder
|
||||
for _, section := range sections {
|
||||
body := renderRootHelpFragments(section.fragments, plan)
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(section.heading)
|
||||
b.WriteString(body)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderRootHelpFragments(fragments []rootHelpFragment, plan *surface.Plan) string {
|
||||
var b strings.Builder
|
||||
for _, fragment := range fragments {
|
||||
if fragment.target != "" && !plan.CanReference(fragment.target) {
|
||||
continue
|
||||
}
|
||||
b.WriteString(fragment.text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var rootUsageSynopsis = []rootHelpFragment{
|
||||
{text: `Usage:
|
||||
lark-cli <command> [subcommand] [method] [flags]`},
|
||||
{target: rootHelpAPI, text: `
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]`},
|
||||
{target: surface.CommandSchema, text: `
|
||||
lark-cli schema <service.resource.method>`},
|
||||
}
|
||||
|
||||
const rootUsageTemplatePrefix = `{{if .HasParent}}Usage:{{if .Runnable}}
|
||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]{{end}}{{else}}`
|
||||
|
||||
// rootUsageTemplateSuffix is Cobra's default usage template after the root
|
||||
// synopsis. Root-only framework affordances are assembled separately above
|
||||
// and below it so each command reference carries an explicit target.
|
||||
const rootUsageTemplateSuffix = `{{end}}{{if gt (len .Aliases) 0}}
|
||||
|
||||
Aliases:
|
||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
|
||||
Examples:
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
||||
|
||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
|
||||
|
||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||
|
||||
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
|
||||
Flags:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
Global Flags:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}`
|
||||
|
||||
// skillsSetupFooter is the root-help pointer at the human one-time skills
|
||||
// setup. It is emitted only while skills/read remains referenceable.
|
||||
const skillsSetupFooter = `{{if not .HasParent}}
|
||||
|
||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}`
|
||||
|
||||
var rootUsageTemplate = renderRootUsageTemplate(nil)
|
||||
|
||||
func renderRootUsageTemplate(plan *surface.Plan) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(rootUsageTemplatePrefix)
|
||||
b.WriteString(renderRootHelpFragments(rootUsageSynopsis, plan))
|
||||
b.WriteString(rootUsageTemplateSuffix)
|
||||
if plan.CanReference(surface.CommandSkillsRead) {
|
||||
b.WriteString(skillsSetupFooter)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
return b.String()
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Com
|
||||
t.Helper()
|
||||
rootCmd.SetArgs(args)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
return handleRootError(f, err, nil)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -371,10 +371,11 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
@@ -504,7 +505,7 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
setupNotices(nil)
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
@@ -538,7 +539,7 @@ func TestSetupNotices_InSync(t *testing.T) {
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
setupNotices(nil)
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice != nil {
|
||||
@@ -571,7 +572,7 @@ func TestSetupNotices_Drift(t *testing.T) {
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
setupNotices(nil)
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
@@ -620,7 +621,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
setupNotices(nil)
|
||||
|
||||
// After setupNotices, skills pending is set (drift). Manually populate
|
||||
// the update side so the composed envelope has both keys — the update
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -12,6 +13,10 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// nilSkills is the skill-content getter used by help-func tests that do
|
||||
// not exercise the domain-guide pointer.
|
||||
func nilSkills() fs.FS { return nil }
|
||||
|
||||
// rendersHelp runs the wrapped help func and returns stdout.
|
||||
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||
t.Helper()
|
||||
@@ -24,7 +29,7 @@ func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||
|
||||
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
installTipsHelpFunc(root, nilSkills, nil, nil)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
@@ -38,7 +43,7 @@ func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
|
||||
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
installTipsHelpFunc(root, nilSkills, nil, nil)
|
||||
|
||||
child := &cobra.Command{Use: "list", Short: "list items"}
|
||||
root.AddCommand(child)
|
||||
@@ -51,7 +56,7 @@ func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
|
||||
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
installTipsHelpFunc(root, nilSkills, nil, nil)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
|
||||
@@ -162,7 +162,7 @@ func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
|
||||
ChallengeURL: "https://example.com/challenge",
|
||||
}
|
||||
|
||||
gotExit := handleRootError(f, spErr)
|
||||
gotExit := handleRootError(f, spErr, nil)
|
||||
if gotExit != int(output.ExitContentSafety) {
|
||||
t.Errorf("exit code = %d, want %d (ExitContentSafety)", gotExit, output.ExitContentSafety)
|
||||
}
|
||||
@@ -209,7 +209,7 @@ func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
gotExit := handleRootError(f, spErr)
|
||||
gotExit := handleRootError(f, spErr, nil)
|
||||
if gotExit != int(output.ExitContentSafety) {
|
||||
t.Errorf("exit code = %d, want %d", gotExit, output.ExitContentSafety)
|
||||
}
|
||||
@@ -286,7 +286,7 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
})
|
||||
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
|
||||
// errs.* error, so it reaches the deprecation fallback.
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"), nil)
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
@@ -314,7 +314,7 @@ func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"))
|
||||
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"), nil)
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
|
||||
}
|
||||
@@ -345,7 +345,7 @@ func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, core.NotConfiguredError())
|
||||
exit := handleRootError(f, core.NotConfiguredError(), nil)
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
|
||||
}
|
||||
@@ -393,7 +393,7 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"), nil)
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
@@ -424,7 +424,7 @@ func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF))
|
||||
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF), nil)
|
||||
|
||||
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
||||
if got := errObj["type"]; got != "internal" {
|
||||
@@ -449,7 +449,7 @@ func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
|
||||
f.IOStreams.ErrOut = w
|
||||
|
||||
err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired")
|
||||
exit := handleRootError(f, err)
|
||||
exit := handleRootError(f, err, nil)
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (typed exit code preserved despite write failure)", exit, int(output.ExitAuth))
|
||||
}
|
||||
@@ -466,7 +466,7 @@ func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, output.ErrBare(output.ExitAuth))
|
||||
exit := handleRootError(f, output.ErrBare(output.ExitAuth), nil)
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth))
|
||||
}
|
||||
@@ -492,7 +492,7 @@ func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
|
||||
WithHint("custom producer hint").
|
||||
WithCause(innerLegacy)
|
||||
|
||||
exit := handleRootError(f, outer)
|
||||
exit := handleRootError(f, outer, nil)
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -28,6 +29,8 @@ var runRootUpgrade = func(cmd *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
var checkRootCachedUpdate = update.CheckCached
|
||||
|
||||
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
|
||||
// no flags) — the only invocation that triggers the interactive upgrade prompt.
|
||||
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
|
||||
@@ -51,7 +54,10 @@ func readYes(r io.Reader) bool {
|
||||
// offerRootUpgrade prompts for an interactive upgrade when running bare
|
||||
// `lark-cli` in an interactive terminal with a cached newer version. Every
|
||||
// failure is swallowed — it must never affect help output or the exit code.
|
||||
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command, projector *recovery.Projector) {
|
||||
if f == nil || !projector.CanReference(recovery.TargetUpdate) {
|
||||
return
|
||||
}
|
||||
ios := f.IOStreams
|
||||
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
|
||||
// stdout TTY too so this only fires in a pure foreground terminal session.
|
||||
@@ -61,7 +67,7 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
// 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)
|
||||
info := checkRootCachedUpdate(build.Version)
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
@@ -76,14 +82,18 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
|
||||
// invocation offers an interactive upgrade before printing help. Non-bare
|
||||
// invocations are passed straight through, unchanged.
|
||||
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
|
||||
func installRootUpgradePrompt(
|
||||
f *cmdutil.Factory,
|
||||
root *cobra.Command,
|
||||
projector *recovery.Projector,
|
||||
) {
|
||||
inner := root.RunE
|
||||
if inner == nil {
|
||||
return
|
||||
}
|
||||
root.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if isBareRootInvocation(args) {
|
||||
offerRootUpgrade(f, cmd)
|
||||
offerRootUpgrade(f, cmd, projector)
|
||||
}
|
||||
return inner(cmd, args)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import (
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -122,7 +125,7 @@ func TestOfferRootUpgrade(t *testing.T) {
|
||||
OutIsTerminal: tc.out,
|
||||
StderrIsTerminal: tc.err,
|
||||
}}
|
||||
offerRootUpgrade(f, &cobra.Command{})
|
||||
offerRootUpgrade(f, &cobra.Command{}, nil)
|
||||
|
||||
gotPrompt := strings.Contains(errBuf.String(), "available")
|
||||
if gotPrompt != tc.wantPrompt {
|
||||
@@ -135,6 +138,34 @@ func TestOfferRootUpgrade(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfferRootUpgradeDoesNotReadCacheWhenUpdateIsConcealed(t *testing.T) {
|
||||
oldCheck := checkRootCachedUpdate
|
||||
t.Cleanup(func() { checkRootCachedUpdate = oldCheck })
|
||||
|
||||
cacheReads := 0
|
||||
checkRootCachedUpdate = func(string) *update.UpdateInfo {
|
||||
cacheReads++
|
||||
return &update.UpdateInfo{Current: "1.0.0", Latest: "2.0.0"}
|
||||
}
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandUpdate: surface.CommandConcealed,
|
||||
})
|
||||
projector := recovery.NewProjector(func() *surface.Plan { return plan })
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader("y\n"),
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &bytes.Buffer{},
|
||||
IsTerminal: true,
|
||||
OutIsTerminal: true,
|
||||
StderrIsTerminal: true,
|
||||
}}
|
||||
|
||||
offerRootUpgrade(f, &cobra.Command{}, projector)
|
||||
if cacheReads != 0 {
|
||||
t.Fatalf("concealed update read cache %d time(s)", cacheReads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
|
||||
orig := rawInvocationArgs
|
||||
t.Cleanup(func() { rawInvocationArgs = orig })
|
||||
@@ -147,7 +178,7 @@ func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root)
|
||||
installRootUpgradePrompt(f, root, nil)
|
||||
|
||||
if err := root.RunE(root, []string{}); err != nil {
|
||||
t.Fatalf("bare RunE err = %v", err)
|
||||
@@ -184,7 +215,7 @@ func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root)
|
||||
installRootUpgradePrompt(f, root, nil)
|
||||
if root.RunE != nil {
|
||||
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/skillref"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -27,6 +28,12 @@ import (
|
||||
// we fall back to it. The pristine base is captured once into an annotation so
|
||||
// re-rendering does not append the guidance twice.
|
||||
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
return PrepareDomainHelpWithReferences(cmd, skillFS, nil)
|
||||
}
|
||||
|
||||
// PrepareDomainHelpWithReferences is PrepareDomainHelp with a build-local
|
||||
// canonical-to-runtime skill projection.
|
||||
func PrepareDomainHelpWithReferences(cmd *cobra.Command, skillFS fs.FS, references *skillref.Resolver) bool {
|
||||
if cmd.Annotations[schemaPathAnnotation] != "" {
|
||||
return false // a method command
|
||||
}
|
||||
@@ -61,10 +68,12 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
|
||||
}
|
||||
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
|
||||
if skill := "lark-" + cmd.Name(); skillFS != nil {
|
||||
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
|
||||
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
|
||||
}
|
||||
canonicalSkill := "lark-" + cmd.Name()
|
||||
if declared, ok := affordance.DomainSkill(cmdmeta.Domain(cmd)); ok {
|
||||
canonicalSkill = declared
|
||||
}
|
||||
if skill, ok := resolveSkillReference(canonicalSkill, skillFS, references); ok {
|
||||
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
|
||||
}
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
@@ -137,6 +146,34 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
|
||||
// affordance.SkillStatPath), so a typo or a build without embedded skills never
|
||||
// prints a `skills read` that cannot be opened.
|
||||
func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
return PrepareMethodHelpWithReferences(cmd, skillFS, nil)
|
||||
}
|
||||
|
||||
// PrepareMethodHelpWithReferences is PrepareMethodHelp with a build-local
|
||||
// canonical-to-runtime skill projection.
|
||||
func PrepareMethodHelpWithReferences(cmd *cobra.Command, skillFS fs.FS, references *skillref.Resolver) bool {
|
||||
return prepareMethodHelp(cmd, skillFS, references, nil)
|
||||
}
|
||||
|
||||
// PrepareMethodHelpWithProjection is PrepareMethodHelpWithReferences with the
|
||||
// command tree's lazy, build-local schema-reference decision. The established
|
||||
// helpers remain fully-visible by default; cmd.Build uses this form so the
|
||||
// framework-owned schema pointer follows the same surface as execution.
|
||||
func PrepareMethodHelpWithProjection(
|
||||
cmd *cobra.Command,
|
||||
skillFS fs.FS,
|
||||
references *skillref.Resolver,
|
||||
canReferenceSchema func() bool,
|
||||
) bool {
|
||||
return prepareMethodHelp(cmd, skillFS, references, canReferenceSchema)
|
||||
}
|
||||
|
||||
func prepareMethodHelp(
|
||||
cmd *cobra.Command,
|
||||
skillFS fs.FS,
|
||||
references *skillref.Resolver,
|
||||
canReferenceSchema func() bool,
|
||||
) bool {
|
||||
ann := cmd.Annotations
|
||||
if ann == nil {
|
||||
return false
|
||||
@@ -161,10 +198,12 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
if canReferenceSchema == nil || canReferenceSchema() {
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
}
|
||||
b.WriteString(ann[paramsOnlyAnnotation])
|
||||
|
||||
writeRelatedSkills(&b, skills, skillFS)
|
||||
writeRelatedSkills(&b, skills, skillFS, references)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
@@ -177,10 +216,9 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
// entry, so shortcuts without guidance keep the default help plus the bottom
|
||||
// risk/tips append.
|
||||
//
|
||||
// The lead is the command's pristine base (captureHelpBase): a shortcut that
|
||||
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
|
||||
// read the skill" directive) keeps it — the affordance block is appended below,
|
||||
// never clobbering it.
|
||||
// The lead is the command's pristine base (captureHelpBase): a shortcut with a
|
||||
// hand-authored Long keeps it, while structured affordance guidance is
|
||||
// appended below without clobbering the business description.
|
||||
//
|
||||
// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The
|
||||
// shortcut's declarative Tips (the Go Tips field) are only a fallback used when
|
||||
@@ -188,6 +226,12 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
// (replaced, not merged) so tips never render twice. Authoring a ### Tips block
|
||||
// therefore silently retires that shortcut's Go Tips — consolidate into one.
|
||||
func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
return PrepareShortcutHelpWithReferences(cmd, skillFS, nil)
|
||||
}
|
||||
|
||||
// PrepareShortcutHelpWithReferences is PrepareShortcutHelp with a build-local
|
||||
// canonical-to-runtime skill projection.
|
||||
func PrepareShortcutHelpWithReferences(cmd *cobra.Command, skillFS fs.FS, references *skillref.Resolver) bool {
|
||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
|
||||
return false
|
||||
}
|
||||
@@ -210,7 +254,7 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
writeRelatedSkills(&b, a.Skills, skillFS)
|
||||
writeRelatedSkills(&b, a.Skills, skillFS, references)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
@@ -234,14 +278,14 @@ func writeRisk(b *strings.Builder, cmd *cobra.Command) {
|
||||
// writeRelatedSkills appends the "Related skills" block for the entries that
|
||||
// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves,
|
||||
// so help never prints a `skills read` pointer that cannot be opened.
|
||||
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
|
||||
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS, references *skillref.Resolver) {
|
||||
if skillFS == nil || len(skills) == 0 {
|
||||
return
|
||||
}
|
||||
var avail []string
|
||||
for _, s := range skills {
|
||||
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
|
||||
avail = append(avail, s)
|
||||
if resolved, ok := resolveSkillReference(s, skillFS, references); ok {
|
||||
avail = append(avail, resolved)
|
||||
}
|
||||
}
|
||||
if len(avail) == 0 {
|
||||
@@ -253,6 +297,22 @@ func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
|
||||
}
|
||||
}
|
||||
|
||||
func resolveSkillReference(canonical string, skillFS fs.FS, references *skillref.Resolver) (string, bool) {
|
||||
// A nil skillFS is also the command-surface gate supplied by cmd.Build:
|
||||
// embedded bytes may still exist, but presenters must not point at them
|
||||
// when `skills read` is concealed.
|
||||
if skillFS == nil {
|
||||
return "", false
|
||||
}
|
||||
if references != nil {
|
||||
return references.ResolveString(canonical)
|
||||
}
|
||||
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(canonical)); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return canonical, true
|
||||
}
|
||||
|
||||
// affordanceLookup is the overlay source; a package var so tests can inject.
|
||||
var affordanceLookup = affordance.For
|
||||
|
||||
|
||||
@@ -9,9 +9,13 @@ import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/skillref"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -142,6 +146,40 @@ func TestPrepareMethodHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareMethodHelpProjectsConcealedSchemaPointer(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||
return json.RawMessage(`{"use_when":["发文本消息"]}`), true
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
m := map[string]interface{}{
|
||||
"id": "messages.create", "path": "messages", "httpMethod": "POST",
|
||||
"description": "发送消息",
|
||||
}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandSchema: surface.CommandConcealed,
|
||||
})
|
||||
projector := recovery.NewProjector(func() *surface.Plan { return plan })
|
||||
|
||||
if !PrepareMethodHelpWithProjection(cmd, nil, nil, func() bool {
|
||||
return projector.CanReference(recovery.TargetSchema)
|
||||
}) {
|
||||
t.Fatal("PrepareMethodHelpWithProjection returned false for a service-method command")
|
||||
}
|
||||
if strings.Contains(cmd.Long, "lark-cli schema") ||
|
||||
strings.Contains(cmd.Long, "Full parameter schema:") {
|
||||
t.Fatalf("concealed schema left a dead method-help pointer:\n%s", cmd.Long)
|
||||
}
|
||||
for _, want := range []string{"发送消息", "When to use:", "发文本消息"} {
|
||||
if !strings.Contains(cmd.Long, want) {
|
||||
t.Errorf("schema projection removed unrelated help %q:\n%s", want, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same
|
||||
// top layout as method help (no schema pointer), folding declarative tips when
|
||||
// the overlay declares none, and leaves shortcuts without an overlay entry (and
|
||||
@@ -233,9 +271,61 @@ func TestRelatedSkillsStatGating(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A shortcut that set a hand-authored Long (as the docs shortcuts do in
|
||||
// PostMount) keeps it as the lead: the affordance block is appended below, not
|
||||
// clobbered, and re-rendering does not double-append.
|
||||
func TestDomainSkillReferenceRequiresReadableCommandSurface(t *testing.T) {
|
||||
content := fstest.MapFS{
|
||||
"lark-im/SKILL.md": {Data: []byte("# im")},
|
||||
}
|
||||
resolver, err := skillref.New(content, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("skillref.New(): %v", err)
|
||||
}
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
domain := &cobra.Command{Use: "im", Short: "IM"}
|
||||
cmdmeta.SetSource(domain, cmdmeta.SourceService, false)
|
||||
domain.AddCommand(&cobra.Command{Use: "messages", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(domain)
|
||||
|
||||
if !PrepareDomainHelpWithReferences(domain, nil, resolver) {
|
||||
t.Fatal("PrepareDomainHelp returned false")
|
||||
}
|
||||
if strings.Contains(domain.Long, "skills read") {
|
||||
t.Fatalf("concealed skills/read leaked through resolver:\n%s", domain.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainSkillReferenceUsesDeclaredAffordanceName(t *testing.T) {
|
||||
affordance.SetSource(fstest.MapFS{
|
||||
"docs.md": {Data: []byte("# docs\n> skill: lark-doc\n")},
|
||||
})
|
||||
t.Cleanup(func() { affordance.SetSource(nil) })
|
||||
content := fstest.MapFS{
|
||||
"lark-doc/SKILL.md": {Data: []byte("# docs")},
|
||||
}
|
||||
resolver, err := skillref.New(content, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("skillref.New(): %v", err)
|
||||
}
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
domain := &cobra.Command{Use: "docs", Short: "Docs"}
|
||||
cmdmeta.SetSource(domain, cmdmeta.SourceService, false)
|
||||
cmdmeta.SetDomain(domain, "docs")
|
||||
domain.AddCommand(&cobra.Command{Use: "documents", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(domain)
|
||||
|
||||
if !PrepareDomainHelpWithReferences(domain, content, resolver) {
|
||||
t.Fatal("PrepareDomainHelp returned false")
|
||||
}
|
||||
if !strings.Contains(domain.Long, "skills read lark-doc") {
|
||||
t.Fatalf("declared domain skill was not used:\n%s", domain.Long)
|
||||
}
|
||||
if strings.Contains(domain.Long, "skills read lark-docs") {
|
||||
t.Fatalf("command-name inference overrode declared domain skill:\n%s", domain.Long)
|
||||
}
|
||||
}
|
||||
|
||||
// A shortcut that sets a hand-authored Long keeps it as the lead: the
|
||||
// affordance block is appended below, not clobbered, and re-rendering does not
|
||||
// double-append.
|
||||
func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
@@ -297,6 +387,26 @@ func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
|
||||
}
|
||||
|
||||
// A service domain carries only a Short at help time; it seeds the base.
|
||||
// The domain-guide pointer is likewise gated: removing the domain's skill
|
||||
// drops the pointer instead of leaving it dangling.
|
||||
func TestPrepareDomainHelp_GatesGuidePointerOnFS(t *testing.T) {
|
||||
present := domainCmd("Consume and manage real-time events", "")
|
||||
if !PrepareDomainHelp(present, fstest.MapFS{"lark-event/SKILL.md": &fstest.MapFile{Data: []byte("x")}}) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if !strings.Contains(present.Long, "lark-cli skills read lark-event") {
|
||||
t.Errorf("skill present should emit the domain-guide pointer; got:\n%s", present.Long)
|
||||
}
|
||||
|
||||
removed := domainCmd("Consume and manage real-time events", "")
|
||||
if !PrepareDomainHelp(removed, fstest.MapFS{}) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if strings.Contains(removed.Long, "skills read lark-event") {
|
||||
t.Errorf("removed skill must leave no domain-guide pointer; got:\n%s", removed.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
||||
dom := domainCmd("Message and group chat management", "")
|
||||
if !PrepareDomainHelp(dom, nil) {
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@@ -597,6 +599,38 @@ func TestServiceMethod_MissingRequired_HintNamesFlagAndParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_MissingRequired_ProjectsOnlySchemaRecovery(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--data", `{"id_list":["ou_x"]}`, "--dry-run"})
|
||||
|
||||
source := cmd.Execute()
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandSchema: surface.CommandConcealed,
|
||||
})
|
||||
rendered := recovery.NewProjector(func() *surface.Plan { return plan }).Render(source)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(rendered, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", rendered, rendered)
|
||||
}
|
||||
for _, want := range []string{"--chat-id", `--params '{"chat_id": "<value>"}'`} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("projected hint %q lost valid recovery %q", ve.Hint, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(ve.Hint, "lark-cli schema") {
|
||||
t.Errorf("projected hint retained concealed schema pointer: %q", ve.Hint)
|
||||
}
|
||||
|
||||
var sourceValidation *errs.ValidationError
|
||||
if !errors.As(source, &sourceValidation) {
|
||||
t.Fatalf("source is not *errs.ValidationError: %T", source)
|
||||
}
|
||||
if !strings.Contains(sourceValidation.Hint, "lark-cli schema im.chat.members.create") {
|
||||
t.Errorf("presentation mutated source hint: %q", sourceValidation.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// A params-only required field (kebab name claimed by the standard --format
|
||||
// flag) has no typed flag to offer: the hint must give only the --params form,
|
||||
// never steer the reader to the colliding flag.
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -490,19 +491,15 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
|
||||
// newPreflightMissingScopeError constructs a PermissionError for the local
|
||||
// pre-flight scope check that converges byte-for-byte with the dispatcher's
|
||||
// BuildAPIError path. Uses the canonical helpers in internal/errclass so
|
||||
// Hint and Message stay in lock-step with the server-response classifier.
|
||||
// BuildAPIError path. It records the same typed facts and canonical message;
|
||||
// the root presenter supplies identity-appropriate recovery at the final
|
||||
// command boundary.
|
||||
// ConsoleURL is deliberately omitted: the dispatcher only sets it for
|
||||
// SubtypeAppScopeNotApplied (bot-perspective dev-action recovery), and this
|
||||
// pre-flight path is user-perspective SubtypeMissingScope whose recovery is
|
||||
// `lark-cli auth login --scope ...`, not a console deep-link.
|
||||
func newPreflightMissingScopeError(brand, appID, identity string, missing []string) *errs.PermissionError {
|
||||
consoleURL := errclass.ConsoleURL(brand, appID, missing)
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")).
|
||||
WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(identity)
|
||||
func newPreflightMissingScopeError(brand, appID, identity string, missing []string) error {
|
||||
return errclass.NewMissingScopeError(brand, appID, identity, missing)
|
||||
}
|
||||
|
||||
// unusableParamValue reports whether a provided path/query parameter value
|
||||
@@ -529,12 +526,28 @@ func unusableParamValue(v interface{}) bool {
|
||||
// only the --params form: a flag with its kebab name exists but belongs to
|
||||
// something else (e.g. the output --format), and the hint must not steer
|
||||
// there. Asking the binder, not cmd.Flags(), is what tells those apart.
|
||||
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) string {
|
||||
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) recovery.Hint {
|
||||
paramsForm := fmt.Sprintf("--params '{%q: \"<value>\"}'", f.Name)
|
||||
var input string
|
||||
if opts.binder.hasTypedFlag(f.Name) {
|
||||
return fmt.Sprintf("set --%s <value> (or %s); see: lark-cli schema %s", f.FlagName(), paramsForm, opts.SchemaPath)
|
||||
input = fmt.Sprintf("set --%s <value> (or %s)", f.FlagName(), paramsForm)
|
||||
} else {
|
||||
input = fmt.Sprintf("set %s", paramsForm)
|
||||
}
|
||||
return fmt.Sprintf("set %s; see: lark-cli schema %s", paramsForm, opts.SchemaPath)
|
||||
return recovery.Join("; ",
|
||||
recovery.Text(input),
|
||||
recovery.Command(recovery.TargetSchema, "see: lark-cli schema "+opts.SchemaPath),
|
||||
)
|
||||
}
|
||||
|
||||
func missingRequiredParamError(opts *ServiceMethodOptions, f meta.Field, location string) error {
|
||||
hint := missingParamHint(opts, f)
|
||||
return recovery.Attach(
|
||||
errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required %s parameter: %s", location, f.Name).
|
||||
WithParam(f.Name),
|
||||
hint,
|
||||
)
|
||||
}
|
||||
|
||||
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
||||
@@ -571,10 +584,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
}
|
||||
val, ok := params[s.Name]
|
||||
if !ok || unusableParamValue(val) {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required path parameter: %s", s.Name).
|
||||
WithHint("%s", missingParamHint(opts, s)).
|
||||
WithParam(s.Name)
|
||||
return client.RawApiRequest{}, nil, missingRequiredParamError(opts, s, "path")
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if err := validate.ResourceName(valStr, s.Name); err != nil {
|
||||
@@ -592,10 +602,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
value, exists := params[s.Name]
|
||||
isPaginationParam := opts.PageAll && (s.Name == "page_token" || s.Name == "page_size")
|
||||
if s.Required && !isPaginationParam && (!exists || unusableParamValue(value)) {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required query parameter: %s", s.Name).
|
||||
WithHint("%s", missingParamHint(opts, s)).
|
||||
WithParam(s.Name)
|
||||
return client.RawApiRequest{}, nil, missingRequiredParamError(opts, s, "query")
|
||||
}
|
||||
if exists && !unusableParamValue(value) {
|
||||
queryParams[s.Name] = value
|
||||
@@ -707,20 +714,18 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
// Streaming formats intentionally emit each page after that page has
|
||||
// passed safety scanning. A later page may still fail, so callers
|
||||
// must use the exit code to distinguish complete vs partial output.
|
||||
scanResult := output.ScanForSafety(commandPath, items, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
400
cmd/service/service_paginate_test.go
Normal file
400
cmd/service/service_paginate_test.go
Normal file
@@ -0,0 +1,400 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type serviceFailOnWriteWriter struct {
|
||||
buf bytes.Buffer
|
||||
writes int
|
||||
failAt int
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *serviceFailOnWriteWriter) Write(p []byte) (int, error) {
|
||||
w.writes++
|
||||
if w.writes == w.failAt {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func newServicePaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
previousNotice := output.PendingNotice
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
||||
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, out, errOut, reg := cmdutil.TestFactory(t, config)
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
|
||||
}
|
||||
ac.ErrOut = io.Discard
|
||||
return ac, out, errOut, reg
|
||||
}
|
||||
|
||||
func servicePaginateRequest() client.RawApiRequest {
|
||||
return client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test/v1/items",
|
||||
As: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
|
||||
t.Helper()
|
||||
wantBytes, err := json.MarshalIndent(want, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal expected JSON: %v", err)
|
||||
}
|
||||
wantBytes = append(wantBytes, '\n')
|
||||
if !bytes.Equal(got, wantBytes) {
|
||||
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
calls := 0
|
||||
wantTokens := []string{"", "next-1", "next-2"}
|
||||
for i, wantToken := range wantTokens {
|
||||
page := i + 1
|
||||
hasMore := page < len(wantTokens)
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = wantTokens[page]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(req *http.Request) {
|
||||
calls++
|
||||
if got := req.URL.Query().Get("page_token"); got != wantToken {
|
||||
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("servicePaginate() error = %v, want nil", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("pagination requests = %d, want 3", calls)
|
||||
}
|
||||
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1"},
|
||||
map[string]interface{}{"id": "2"},
|
||||
map[string]interface{}{"id": "3"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
})
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ndjson",
|
||||
format: output.FormatNDJSON,
|
||||
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
format: output.FormatTable,
|
||||
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
format: output.FormatCSV,
|
||||
want: "id,name\n1,Alice\n2,Carol\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("servicePaginate() error = %v, want nil", err)
|
||||
}
|
||||
if got := out.String(); got != tt.want {
|
||||
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
ac, _, errOut, reg := newServicePaginateTestHarness(t)
|
||||
sentinel := errors.New("page write failed")
|
||||
out := &serviceFailOnWriteWriter{failAt: 2, err: sentinel}
|
||||
calls := 0
|
||||
for page := 1; page <= 2; page++ {
|
||||
hasMore := true
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": page}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = fmt.Sprintf("next-%d", page)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(*http.Request) {
|
||||
calls++
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("servicePaginate() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("servicePaginate() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("pagination requests = %d, want 2", calls)
|
||||
}
|
||||
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
|
||||
t.Fatalf("stdout bytes = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("servicePaginate() error = %v, want nil", err)
|
||||
}
|
||||
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
})
|
||||
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
|
||||
if got := errOut.String(); got != wantWarning {
|
||||
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
|
||||
businessResponse := map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{"detail": "business failed"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "default_json", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: businessResponse,
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("servicePaginate() error = nil, want business error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
}
|
||||
assertServicePaginateJSONBytes(t, out.Bytes(), businessResponse)
|
||||
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
|
||||
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "stream_pages", format: output.FormatNDJSON},
|
||||
{name: "default_paginate_all", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, _ := newServicePaginateTestHarness(t)
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("servicePaginate() error = nil, want transport error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("servicePaginate() error = nil, want business error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,30 @@ func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
|
||||
return meta.FromMap(m)
|
||||
}
|
||||
|
||||
func TestNewPreflightMissingScopeErrorUsesCanonicalFieldGate(t *testing.T) {
|
||||
err := newPreflightMissingScopeError(
|
||||
"feishu",
|
||||
"cli_test",
|
||||
"user",
|
||||
[]string{"docx:document"},
|
||||
)
|
||||
var permissionErr *errs.PermissionError
|
||||
if !errors.As(err, &permissionErr) {
|
||||
t.Fatalf("error = %T, want *errs.PermissionError", err)
|
||||
}
|
||||
if permissionErr.Subtype != errs.SubtypeMissingScope {
|
||||
t.Fatalf("subtype = %q, want %q", permissionErr.Subtype, errs.SubtypeMissingScope)
|
||||
}
|
||||
if permissionErr.ConsoleURL != "" {
|
||||
t.Fatalf("missing_scope console_url = %q, want empty", permissionErr.ConsoleURL)
|
||||
}
|
||||
if len(permissionErr.MissingScopes) != 1 ||
|
||||
permissionErr.MissingScopes[0] != "docx:document" ||
|
||||
permissionErr.Identity != "user" {
|
||||
t.Fatalf("permission facts = %+v", permissionErr)
|
||||
}
|
||||
}
|
||||
|
||||
// ── registerService ──
|
||||
|
||||
func TestRegisterService(t *testing.T) {
|
||||
|
||||
39
cmd/service/testmain_test.go
Normal file
39
cmd/service/testmain_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates service command tests from the host machine: config (and
|
||||
// the registry cache under it) is redirected to a temp dir, then the registry
|
||||
// is seeded from the tracked fixture and initialized eagerly. Tests pass on a
|
||||
// clean checkout with no network, no `make fetch_meta`, and no user cache.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-service-test-*")
|
||||
if err != nil {
|
||||
println("cmd/service test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
|
||||
println("cmd/service test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd/service test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
208
cmd/skill_customization_test.go
Normal file
208
cmd/skill_customization_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/skillcontent"
|
||||
)
|
||||
|
||||
// withBaseSkills swaps the process-global embedded skill tree for the
|
||||
// duration of a test, restoring it afterward.
|
||||
func withBaseSkills(t *testing.T, files map[string]string) {
|
||||
t.Helper()
|
||||
base := fstest.MapFS{}
|
||||
for p, content := range files {
|
||||
base[p] = &fstest.MapFile{Data: []byte(content)}
|
||||
}
|
||||
saved := embeddedSkillContent
|
||||
t.Cleanup(func() { embeddedSkillContent = saved })
|
||||
embeddedSkillContent = base
|
||||
}
|
||||
|
||||
// A plugin's SkillsOverlay must reshape the tree the factory serves: skills
|
||||
// list/read read f.SkillContent, so a resolved removal/overlay shows up here.
|
||||
// (Framework-generated --help pointers are gated on the same f.SkillContent;
|
||||
// that gating is covered by the PrepareDomainHelp/PrepareMethodHelp tests in
|
||||
// cmd/service.)
|
||||
func TestBuildInternal_appliesPluginSkillsOverlay(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{
|
||||
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
|
||||
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
|
||||
"lark-shared/SKILL.md": "---\ndescription: shared\n---\n",
|
||||
})
|
||||
|
||||
overlay := fstest.MapFS{
|
||||
"lark-new/SKILL.md": &fstest.MapFile{Data: []byte("---\ndescription: new\n---\n")},
|
||||
}
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{
|
||||
Remove: []string{"lark-shared"},
|
||||
Overlay: overlay,
|
||||
}).MustBuild())
|
||||
|
||||
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if f.SkillContent == nil {
|
||||
t.Fatal("f.SkillContent is nil after skill resolution")
|
||||
}
|
||||
|
||||
skills, err := skillcontent.New(f.SkillContent).List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range skills {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if got := strings.Join(names, ","); got != "lark-a,lark-b,lark-new" {
|
||||
t.Errorf("skills = %q, want lark-a,lark-b,lark-new (shared removed, new added)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Two plugins each customizing skills must abort at dispatch with a
|
||||
// structured envelope carrying reason_code multiple_skills_overlay_plugins, not
|
||||
// silently fall back to the default tree.
|
||||
func TestBuildInternal_multipleSkillPluginsGuard(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
|
||||
platform.Register(platform.NewPlugin("globex", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("skill conflict guard path should yield nil registry")
|
||||
}
|
||||
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatal("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "multiple_skills_overlay_plugins") {
|
||||
t.Errorf("hint should surface reason_code multiple_skills_overlay_plugins, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow keeps only the listed skills from the base — a CLI upgrade adding
|
||||
// new embedded skills cannot widen an allow-listed build.
|
||||
func TestBuildInternal_appliesAllowList(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{
|
||||
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
|
||||
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
|
||||
"lark-c/SKILL.md": "---\ndescription: c\n---\n",
|
||||
})
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Allow: []string{"lark-a", "lark-c"}}).
|
||||
MustBuild())
|
||||
|
||||
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
skills, err := skillcontent.New(f.SkillContent).List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range skills {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if got := strings.Join(names, ","); got != "lark-a,lark-c" {
|
||||
t.Errorf("skills = %q, want lark-a,lark-c (allow-list)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin whose SkillsOverlay cannot compose (Remove naming a skill absent
|
||||
// from the base) must abort with reason_code invalid_skills_overlay.
|
||||
func TestBuildInternal_invalidSkillsOverlayGuard(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-does-not-exist"}}).MustBuild())
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatal("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "invalid_skills_overlay") {
|
||||
t.Errorf("hint should surface reason_code invalid_skills_overlay, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// A wrapper main that forgets to wire its embedded skill base should get the
|
||||
// missing host assembly step, not the same recovery hint as a misspelled
|
||||
// Allow/Remove name.
|
||||
func TestBuildInternal_missingBaseSkillsGuardHint(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
saved := embeddedSkillContent
|
||||
t.Cleanup(func() { embeddedSkillContent = saved })
|
||||
embeddedSkillContent = nil
|
||||
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatal("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "this build embeds no base skill content") {
|
||||
t.Errorf("hint should name the missing embedded content, got %q", verr.Hint)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "cmd.SetEmbeddedSkillContent") {
|
||||
t.Errorf("hint should name the wrapper-main wiring API, got %q", verr.Hint)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "invalid_skills_overlay") {
|
||||
t.Errorf("hint should preserve reason_code invalid_skills_overlay, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -12,11 +13,34 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
const startupBrandHelperEnv = "GO_TEST_STARTUP_BRAND_HELPER"
|
||||
|
||||
var _ = flag.String("startup-brand-helper", "", "internal startup brand test helper nonce")
|
||||
|
||||
func isStartupBrandHelper() bool {
|
||||
return startupBrandHelperEnabled(os.Getenv(startupBrandHelperEnv), startupBrandHelperNonce(os.Args))
|
||||
}
|
||||
|
||||
func startupBrandHelperEnabled(envNonce, argNonce string) bool {
|
||||
return envNonce != "" && envNonce == argNonce
|
||||
}
|
||||
|
||||
func startupBrandHelperNonce(args []string) string {
|
||||
const prefix = "-startup-brand-helper="
|
||||
for _, arg := range args {
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
return strings.TrimPrefix(arg, prefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestResolveStartupBrand_Precedence(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
@@ -54,7 +78,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
|
||||
// sync.Once, so the brand must be injected before the first catalog access.
|
||||
// It runs in a subprocess because the registry is process-global.
|
||||
func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
|
||||
if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
|
||||
if isStartupBrandHelper() {
|
||||
// Helper: replicate Execute()'s build wiring with a lark config.
|
||||
buildInternal(
|
||||
context.Background(), cmdutil.InvocationContext{},
|
||||
@@ -71,9 +95,11 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
nonce := uuid.NewString()
|
||||
t.Setenv(startupBrandHelperEnv, nonce)
|
||||
cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder")
|
||||
cmd.Args = append(cmd.Args, "-startup-brand-helper="+nonce)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GO_TEST_STARTUP_BRAND_HELPER=1",
|
||||
"LARKSUITE_CLI_CONFIG_DIR="+tmp,
|
||||
"LARKSUITE_CLI_REMOTE_META=off", // no network during the subprocess build
|
||||
)
|
||||
@@ -85,3 +111,33 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
|
||||
t.Errorf("registry brand after real startup order = %s, want lark", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupBrandHelperRequiresMatchingCommandNonce(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
envNonce string
|
||||
argNonce string
|
||||
want bool
|
||||
}{
|
||||
{name: "neither set"},
|
||||
{name: "ambient environment only", envNonce: "ambient"},
|
||||
{name: "command argument only", argNonce: "command"},
|
||||
{name: "mismatch", envNonce: "ambient", argNonce: "command"},
|
||||
{name: "matching", envNonce: "nonce", argNonce: "nonce", want: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := startupBrandHelperEnabled(tt.envNonce, tt.argNonce); got != tt.want {
|
||||
t.Fatalf("startupBrandHelperEnabled() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupBrandHelperNonce(t *testing.T) {
|
||||
if got := startupBrandHelperNonce([]string{"test", "-test.run", "brand"}); got != "" {
|
||||
t.Fatalf("startupBrandHelperNonce() = %q, want empty", got)
|
||||
}
|
||||
if got := startupBrandHelperNonce([]string{"test", "-startup-brand-helper=nonce"}); got != "nonce" {
|
||||
t.Fatalf("startupBrandHelperNonce() = %q, want nonce", got)
|
||||
}
|
||||
}
|
||||
|
||||
46
cmd/testmain_test.go
Normal file
46
cmd/testmain_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates command-tree tests from the host machine: config (and the
|
||||
// registry cache under it) is redirected to a temp dir, then the registry is
|
||||
// seeded from the tracked fixture and initialized eagerly. Tests pass on a
|
||||
// clean checkout with no network, no `make fetch_meta`, and no user cache.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
if isStartupBrandHelper() {
|
||||
// Re-exec helper subprocess (startup_brand_test.go): the parent test
|
||||
// already provides an isolated config dir and disables remote metadata,
|
||||
// and the helper must own the first registry Init to prove the startup
|
||||
// order — do not seed or eagerly initialize here.
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-test-*")
|
||||
if err != nil {
|
||||
println("cmd test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
|
||||
println("cmd test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
23
cmd/update/testmain_test.go
Normal file
23
cmd/update/testmain_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-update-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
)
|
||||
|
||||
const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS"
|
||||
|
||||
// newTestFactory creates a test factory with minimal config.
|
||||
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
@@ -31,13 +33,17 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult
|
||||
// and fully mocked skills operations. Tests that only care about install-method
|
||||
// detection must never fall through to the real npx skills CLI.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
@@ -104,6 +110,18 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
}
|
||||
}
|
||||
|
||||
func mockSkillsSync(t *testing.T) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -228,6 +246,9 @@ func TestNormalizeVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
mockSkillsSync(t)
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
@@ -256,6 +277,9 @@ func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
mockSkillsSync(t)
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
@@ -281,6 +305,7 @@ func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateManual_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
@@ -312,6 +337,7 @@ func TestUpdateManual_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateManual_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
@@ -1161,6 +1187,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1177,7 +1204,10 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: successfulSkillsCommand(),
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
@@ -1197,6 +1227,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("npx failed")
|
||||
@@ -1513,28 +1544,133 @@ func TestEmitSkillsTextHints_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
|
||||
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
|
||||
// state file. It calls the real npx skills CLI, so the test is skipped when
|
||||
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
|
||||
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
// liveSkillsIsolationEnv is the single source of truth for the user-state
|
||||
// directories a live skills test must redirect under the temporary home. It
|
||||
// covers the CLI's own config, the agent homes the skills CLI installs into,
|
||||
// the XDG dirs it derives paths from (XDG_STATE_HOME holds its global
|
||||
// .skill-lock.json), and the npm/npx overrides that take precedence over
|
||||
// HOME-derived defaults (both cases: npm reads npm_config_* case-insensitively).
|
||||
func liveSkillsIsolationEnv(home string) map[string]string {
|
||||
return map[string]string{
|
||||
"HOME": home,
|
||||
"USERPROFILE": home,
|
||||
"APPDATA": filepath.Join(home, "AppData", "Roaming"),
|
||||
"LOCALAPPDATA": filepath.Join(home, "AppData", "Local"),
|
||||
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
|
||||
"XDG_DATA_HOME": filepath.Join(home, ".local", "share"),
|
||||
"XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
|
||||
"CODEX_HOME": filepath.Join(home, ".codex"),
|
||||
"CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude"),
|
||||
"LARKSUITE_CLI_CONFIG_DIR": filepath.Join(home, ".lark-cli"),
|
||||
"npm_config_cache": filepath.Join(home, ".npm-cache"),
|
||||
"NPM_CONFIG_CACHE": filepath.Join(home, ".npm-cache"),
|
||||
"npm_config_prefix": filepath.Join(home, ".npm-global"),
|
||||
"NPM_CONFIG_PREFIX": filepath.Join(home, ".npm-global"),
|
||||
"npm_config_userconfig": filepath.Join(home, ".npmrc"),
|
||||
"NPM_CONFIG_USERCONFIG": filepath.Join(home, ".npmrc"),
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
}
|
||||
|
||||
func prepareLiveSkillsIntegration(t *testing.T) string {
|
||||
t.Helper()
|
||||
if os.Getenv(runLiveSkillsTestsEnv) != "1" {
|
||||
t.Skipf("live skills integration test disabled; set %s=1 to run", runLiveSkillsTestsEnv)
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
for key, value := range liveSkillsIsolationEnv(home) {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
func TestPrepareLiveSkillsIntegration(t *testing.T) {
|
||||
reachedAfterGate := false
|
||||
t.Run("requires explicit opt-in", func(t *testing.T) {
|
||||
t.Setenv(runLiveSkillsTestsEnv, "")
|
||||
prepareLiveSkillsIntegration(t)
|
||||
reachedAfterGate = true
|
||||
})
|
||||
if reachedAfterGate {
|
||||
t.Fatal("prepareLiveSkillsIntegration continued without explicit opt-in")
|
||||
}
|
||||
|
||||
t.Run("isolates user directories", func(t *testing.T) {
|
||||
t.Setenv(runLiveSkillsTestsEnv, "1")
|
||||
home := prepareLiveSkillsIntegration(t)
|
||||
// Pin the isolation contract by key: removing a variable from
|
||||
// liveSkillsIsolationEnv must fail this list, and every redirected
|
||||
// value must live under the temporary home.
|
||||
required := []string{
|
||||
"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
|
||||
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME",
|
||||
"CODEX_HOME", "CLAUDE_CONFIG_DIR", "LARKSUITE_CLI_CONFIG_DIR",
|
||||
"npm_config_cache", "NPM_CONFIG_CACHE",
|
||||
"npm_config_prefix", "NPM_CONFIG_PREFIX",
|
||||
"npm_config_userconfig", "NPM_CONFIG_USERCONFIG",
|
||||
}
|
||||
env := liveSkillsIsolationEnv(home)
|
||||
for _, key := range required {
|
||||
expected, ok := env[key]
|
||||
if !ok {
|
||||
t.Errorf("liveSkillsIsolationEnv dropped required key %s", key)
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(expected, home) {
|
||||
t.Errorf("%s = %q escapes temporary home %q", key, expected, home)
|
||||
}
|
||||
if got := os.Getenv(key); got != expected {
|
||||
t.Errorf("%s = %q, want %q", key, got, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// seedLiveSkillsGlobal verifies the real npx skills CLI is reachable, installs
|
||||
// lark-calendar into the isolated global skills dir, and returns the parsed
|
||||
// global skills list. The caller opted in explicitly, so every missing
|
||||
// precondition is a hard failure — skipping would report "nothing verified"
|
||||
// as a green run.
|
||||
func seedLiveSkillsGlobal(t *testing.T) []string {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Fatalf("live skills tests opted in but npx not found in PATH: %v", err)
|
||||
}
|
||||
// Three sequential npx runs against a cold cache (the isolated home starts
|
||||
// empty) can be slow; with Fatal-on-timeout semantics the budget errs on
|
||||
// the generous side.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
t.Fatalf("live skills tests opted in but real skills CLI unavailable: %v", err)
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "-s", "lark-calendar", "-g", "-y").Run(); err != nil {
|
||||
t.Fatalf("failed to seed isolated global skills: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
t.Fatalf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
if len(localSkills) == 0 {
|
||||
t.Fatal("seeded lark-calendar but global skills list is empty")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Fatalf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
return localSkills
|
||||
}
|
||||
|
||||
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
|
||||
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
|
||||
// state file. It calls the real npx skills CLI and only runs with explicit
|
||||
// opt-in. All user directories are redirected to a temporary home.
|
||||
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
prepareLiveSkillsIntegration(t)
|
||||
|
||||
// Phase 1: Verify the real npx skills CLI is available and seed the
|
||||
// isolated global skills install.
|
||||
localSkills := seedLiveSkillsGlobal(t)
|
||||
|
||||
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
|
||||
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
|
||||
@@ -1630,26 +1766,17 @@ func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
// not exist (cold start), the update command installs all official skills and
|
||||
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
|
||||
// because there is no previous state to preserve user deletions from.
|
||||
// This is a live integration test that calls the real npx skills CLI; it is
|
||||
// skipped when npx or the skills registry is unavailable.
|
||||
// This is a live integration test that calls the real npx skills CLI and only
|
||||
// runs with explicit opt-in. All user directories are redirected to a temporary
|
||||
// home.
|
||||
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
prepareLiveSkillsIntegration(t)
|
||||
|
||||
// Phase 1: Verify the real npx skills CLI is available and seed one known
|
||||
// official skill into the isolated global install. Cold start means no
|
||||
// skills-state.json — locally installed skills may still exist, and seeding
|
||||
// one keeps the Phase 4 per-skill assertions from running zero times.
|
||||
localSkills := seedLiveSkillsGlobal(t)
|
||||
|
||||
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
)
|
||||
|
||||
// whoamiResult is the structured output of `lark-cli whoami`.
|
||||
@@ -54,12 +55,22 @@ type Options struct {
|
||||
// local-only; when an external credential provider manages tokens, resolving
|
||||
// the identity may contact that provider.
|
||||
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
return newCmdWhoami(f, nil)
|
||||
}
|
||||
|
||||
// NewCmdWhoamiWithRecovery creates whoami with a build-local recovery
|
||||
// presenter while preserving NewCmdWhoami's established function signature.
|
||||
func NewCmdWhoamiWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
return newCmdWhoami(f, projector)
|
||||
}
|
||||
|
||||
func newCmdWhoami(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
opts := &Options{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current effective identity, app, profile, and token status (JSON)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return whoamiRun(cmd, opts)
|
||||
return whoamiRun(cmd, opts, projector)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
@@ -73,7 +84,7 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
func whoamiRun(cmd *cobra.Command, opts *Options, projector *recovery.Projector) error {
|
||||
f := opts.Factory
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
@@ -96,7 +107,10 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
f.IdentityAutoDetected,
|
||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
diag := identitydiag.FilterRecovery(
|
||||
identitydiag.Diagnose(ctx, f, cfg, false),
|
||||
projector.CanReference,
|
||||
)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
)
|
||||
|
||||
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
|
||||
@@ -36,6 +35,6 @@ func init() {
|
||||
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
|
||||
} else {
|
||||
affordance.SetSource(sub)
|
||||
cmd.SetEmbeddedAffordanceContent(sub)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +509,9 @@ Rare; the existing structs cover the 9 Categories with room. If you must:
|
||||
1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field.
|
||||
2. Add an `IsXxx` predicate in `errs/predicates.go`.
|
||||
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_test.go`.
|
||||
4. Add the concrete type and deep-copy handling to
|
||||
`internal/recovery.CloneTyped`, then extend
|
||||
`TestRenderClonesEveryConcreteTypedErrorAndPreservesWireExtensions`.
|
||||
|
||||
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
|
||||
top-level wire fields are forbidden — per-Subtype data goes into the
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeCommandUnavailable Subtype = "command_unavailable" // command not included in this build (integrator-restricted distribution); absent, not gated
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
|
||||
107
events/application/menu.go
Normal file
107
events/application/menu.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// BotMenuOutput is the flattened shape for application.bot.menu_v6.
|
||||
type BotMenuOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always application.bot.menu_v6"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
AppID string `json:"app_id,omitempty" desc:"Application ID from the event header"`
|
||||
TenantKey string `json:"tenant_key,omitempty" desc:"Tenant key from the event header"`
|
||||
EventKey string `json:"event_key,omitempty" desc:"Developer-defined bot menu event key"`
|
||||
MenuTimestamp string `json:"menu_timestamp,omitempty" desc:"Menu click timestamp from the event body" kind:"timestamp_ms"`
|
||||
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id; kept as a short alias of operator_open_id" kind:"open_id"`
|
||||
OperatorOpenID string `json:"operator_open_id,omitempty" desc:"Operator open_id" kind:"open_id"`
|
||||
OperatorUnionID string `json:"operator_union_id,omitempty" desc:"Operator union_id" kind:"union_id"`
|
||||
OperatorUserID string `json:"operator_user_id,omitempty" desc:"Operator user_id" kind:"user_id"`
|
||||
OperatorName string `json:"operator_name,omitempty" desc:"Operator display name"`
|
||||
}
|
||||
|
||||
func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
AppID string `json:"app_id"`
|
||||
TenantKey string `json:"tenant_key"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
EventKey string `json:"event_key"`
|
||||
Timestamp json.RawMessage `json:"timestamp"`
|
||||
Operator struct {
|
||||
OperatorID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
UnionID string `json:"union_id"`
|
||||
UserID string `json:"user_id"`
|
||||
} `json:"operator_id"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
} `json:"operator"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
|
||||
timestamp := envelope.Header.CreateTime
|
||||
if timestamp == "" {
|
||||
timestamp = menuTimestamp
|
||||
}
|
||||
operatorID := envelope.Event.Operator.OperatorID.OpenID
|
||||
|
||||
out := &BotMenuOutput{
|
||||
Type: eventTypeBotMenuV6,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: timestamp,
|
||||
AppID: envelope.Header.AppID,
|
||||
TenantKey: envelope.Header.TenantKey,
|
||||
EventKey: envelope.Event.EventKey,
|
||||
MenuTimestamp: menuTimestamp,
|
||||
OperatorID: operatorID,
|
||||
OperatorOpenID: operatorID,
|
||||
OperatorUnionID: envelope.Event.Operator.OperatorID.UnionID,
|
||||
OperatorUserID: envelope.Event.Operator.OperatorID.UserID,
|
||||
OperatorName: envelope.Event.Operator.OperatorName,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func rawScalarString(raw json.RawMessage) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return ""
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
return text
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func timestampMillisString(raw json.RawMessage) string {
|
||||
s := rawScalarString(raw)
|
||||
if len(s) == 10 && allDigits(s) {
|
||||
return s + "000"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func allDigits(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
227
events/application/menu_test.go
Normal file
227
events/application/menu_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestKeysBotMenuMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
|
||||
}
|
||||
|
||||
def := keys[0]
|
||||
if def.Key != eventTypeBotMenuV6 {
|
||||
t.Errorf("Key = %q, want %q", def.Key, eventTypeBotMenuV6)
|
||||
}
|
||||
if def.EventType != eventTypeBotMenuV6 {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeBotMenuV6)
|
||||
}
|
||||
if def.SubscriptionType != "" {
|
||||
t.Errorf("SubscriptionType = %q, want default event subscription", def.SubscriptionType)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Fatal("Schema.Custom is nil")
|
||||
}
|
||||
if def.Schema.Custom.Type != reflect.TypeOf(BotMenuOutput{}) {
|
||||
t.Errorf("custom type = %v, want BotMenuOutput", def.Schema.Custom.Type)
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Fatal("Schema.Native must be nil for processed output")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Fatal("Process is nil")
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
|
||||
t.Errorf("AuthTypes = %#v", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeBotMenuV6}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotMenuRegistersCleanly(t *testing.T) {
|
||||
const key = eventTypeBotMenuV6
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenu(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_001",
|
||||
"event_type": "application.bot.menu_v6",
|
||||
"create_time": "1776409469273",
|
||||
"app_id": "cli_test",
|
||||
"tenant_key": "tenant_test"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": 1776409469000,
|
||||
"operator": {
|
||||
"operator_id": {
|
||||
"open_id": "ou_operator",
|
||||
"union_id": "on_operator",
|
||||
"user_id": "user_operator"
|
||||
},
|
||||
"operator_name": "Test User"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Type != eventTypeBotMenuV6 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
|
||||
}
|
||||
if out.EventID != "ev_menu_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
if out.EventKey != "start_eval" {
|
||||
t.Errorf("EventKey = %q", out.EventKey)
|
||||
}
|
||||
if out.MenuTimestamp != "1776409469000" {
|
||||
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
|
||||
}
|
||||
if out.OperatorID != "ou_operator" || out.OperatorOpenID != "ou_operator" {
|
||||
t.Errorf("OperatorID/OperatorOpenID = %q/%q", out.OperatorID, out.OperatorOpenID)
|
||||
}
|
||||
if out.OperatorUnionID != "on_operator" {
|
||||
t.Errorf("OperatorUnionID = %q", out.OperatorUnionID)
|
||||
}
|
||||
if out.OperatorUserID != "user_operator" {
|
||||
t.Errorf("OperatorUserID = %q", out.OperatorUserID)
|
||||
}
|
||||
if out.OperatorName != "Test User" {
|
||||
t.Errorf("OperatorName = %q", out.OperatorName)
|
||||
}
|
||||
if out.AppID != "cli_test" || out.TenantKey != "tenant_test" {
|
||||
t.Errorf("AppID/TenantKey = %q/%q", out.AppID, out.TenantKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuStringTimestampFallback(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_002",
|
||||
"event_type": "application.bot.menu_v6"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": "1776409469001",
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Timestamp != "1776409469001" {
|
||||
t.Errorf("Timestamp fallback = %q", out.Timestamp)
|
||||
}
|
||||
if out.MenuTimestamp != "1776409469001" {
|
||||
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuSecondsTimestampFallback(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_seconds",
|
||||
"event_type": "application.bot.menu_v6"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": 1694592375,
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Timestamp != "1694592375000" {
|
||||
t.Errorf("Timestamp fallback = %q, want seconds normalized to milliseconds", out.Timestamp)
|
||||
}
|
||||
if out.MenuTimestamp != "1694592375000" {
|
||||
t.Errorf("MenuTimestamp = %q, want seconds normalized to milliseconds", out.MenuTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuTypeUsesLocalConstant(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_003",
|
||||
"event_type": "unexpected.event_type",
|
||||
"create_time": "1776409469275"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Type != eventTypeBotMenuV6 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuMalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processBotMenu(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func runBotMenu(t *testing.T, payload string) BotMenuOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processBotMenu(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("processBotMenu: %v", err)
|
||||
}
|
||||
var out BotMenuOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\n%s", err, got)
|
||||
}
|
||||
return out
|
||||
}
|
||||
31
events/application/register.go
Normal file
31
events/application/register.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package application registers Application-domain EventKeys.
|
||||
package application
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const eventTypeBotMenuV6 = "application.bot.menu_v6"
|
||||
|
||||
// Keys returns all Application-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeBotMenuV6,
|
||||
DisplayName: "Bot menu",
|
||||
Description: "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(BotMenuOutput{})},
|
||||
},
|
||||
Process: processBotMenu,
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeBotMenuV6},
|
||||
},
|
||||
}
|
||||
}
|
||||
155
events/approval/preconsume.go
Normal file
155
events/approval/preconsume.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
type approvalEventType string
|
||||
type approvalSubscriptionPath string
|
||||
|
||||
type approvalSubscriptionConfig struct {
|
||||
eventType approvalEventType
|
||||
subscribePath approvalSubscriptionPath
|
||||
}
|
||||
|
||||
func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
eventType := string(cfg.eventType)
|
||||
subscribePath := string(cfg.subscribePath)
|
||||
subscriptionTypes, err := approvalSubscriptionTypes(eventType, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registered := make([]string, 0, len(subscriptionTypes))
|
||||
for _, subscriptionType := range subscriptionTypes {
|
||||
body := map[string]string{"subscription_type": subscriptionType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, approvalSubscriptionRegistrationError(eventType, registered, subscriptionType, err)
|
||||
}
|
||||
registered = append(registered, subscriptionType)
|
||||
}
|
||||
|
||||
// Approval subscriptions are durable user-auth relations. Consuming events
|
||||
// should not cancel that relation when this local process exits.
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func approvalSubscriptionTypes(eventType string, params map[string]string) ([]string, error) {
|
||||
raw := strings.TrimSpace(params["subscription_type"])
|
||||
if raw == "" {
|
||||
return append([]string(nil), approvalAllSubscriptionTypes...), nil
|
||||
}
|
||||
|
||||
values, err := parseApprovalSubscriptionTypeValues(raw)
|
||||
if err != nil {
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
|
||||
}
|
||||
|
||||
selected := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
switch value {
|
||||
case approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged:
|
||||
selected[value] = true
|
||||
default:
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, value)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(selected))
|
||||
for _, value := range approvalAllSubscriptionTypes {
|
||||
if selected[value] {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseApprovalSubscriptionTypeValues(raw string) ([]string, error) {
|
||||
if strings.HasPrefix(raw, "[") {
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
return strings.Split(raw, ","), nil
|
||||
}
|
||||
|
||||
func approvalSubscriptionRegistrationError(eventType string, registered []string, failed string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"approval subscription pre-consume failed for EventKey %s: failed subscription_type %s",
|
||||
eventType,
|
||||
failed,
|
||||
)
|
||||
hint := fmt.Sprintf(
|
||||
"no approval subscription relation was registered for EventKey %s; fix the cause and retry",
|
||||
eventType,
|
||||
)
|
||||
if len(registered) > 0 {
|
||||
msg = fmt.Sprintf(
|
||||
"approval subscription pre-consume partially completed for EventKey %s: registered subscription_type(s) [%s], failed subscription_type %s",
|
||||
eventType,
|
||||
strings.Join(registered, ", "),
|
||||
failed,
|
||||
)
|
||||
hint = fmt.Sprintf(
|
||||
"server-side approval subscription relation(s) already registered for EventKey %s: %s; after fixing the cause, retry with --param subscription_type=%s to register the failed relation",
|
||||
eventType,
|
||||
strings.Join(registered, ", "),
|
||||
failed,
|
||||
)
|
||||
}
|
||||
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if upstream := strings.TrimSpace(p.Message); upstream != "" {
|
||||
p.Message = msg + ": " + upstream
|
||||
} else {
|
||||
p.Message = msg
|
||||
}
|
||||
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
|
||||
p.Hint = upstreamHint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).
|
||||
WithHint("%s", hint).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
func invalidApprovalSubscriptionTypeError(eventType, value string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid subscription_type for EventKey %s: %q", eventType, value).
|
||||
WithParam("--param").
|
||||
WithHint("omit subscription_type to register both approval subscription relations, or pass --param subscription_type=%s, --param subscription_type=%s, or --param subscription_type=%s,%s; run `lark-cli event schema %s` for details",
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
eventType)
|
||||
}
|
||||
179
events/approval/register.go
Normal file
179
events/approval/register.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package approval registers Approval-domain EventKeys.
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeApprovalInstanceStatusChangedV4 = "approval.instance.status_changed_v4"
|
||||
eventTypeApprovalTaskStatusChangedV4 = "approval.task.status_changed_v4"
|
||||
|
||||
pathApprovalInstancesSubscription = "/open-apis/approval/v4/instances/subscription"
|
||||
pathApprovalTasksSubscription = "/open-apis/approval/v4/tasks/subscription"
|
||||
|
||||
approvalSubscriptionTypeInvolved = "INVOLVED_APPROVAL"
|
||||
approvalSubscriptionTypeManaged = "MANAGED_APPROVAL"
|
||||
)
|
||||
|
||||
var approvalAllSubscriptionTypes = []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
}
|
||||
|
||||
// Keys returns all Approval-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeApprovalInstanceStatusChangedV4,
|
||||
DisplayName: "Approval instance status changed",
|
||||
Description: "Triggered after an approval instance status becomes visible to the requester or approval participants",
|
||||
EventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
Params: approvalSubscriptionParams(),
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})},
|
||||
},
|
||||
Process: processApprovalInstanceStatusChanged,
|
||||
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
}),
|
||||
Scopes: []string{"approval:instance:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeApprovalInstanceStatusChangedV4},
|
||||
},
|
||||
{
|
||||
Key: eventTypeApprovalTaskStatusChangedV4,
|
||||
DisplayName: "Approval task status changed",
|
||||
Description: "Triggered after an approval task status becomes visible to the requester or task approver",
|
||||
EventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
Params: approvalSubscriptionParams(),
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})},
|
||||
},
|
||||
Process: processApprovalTaskStatusChanged,
|
||||
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
}),
|
||||
Scopes: []string{"approval:task:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeApprovalTaskStatusChangedV4},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func approvalSubscriptionParams() []event.ParamDef {
|
||||
return []event.ParamDef{
|
||||
{
|
||||
Name: "subscription_type",
|
||||
Type: event.ParamMulti,
|
||||
Description: "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.",
|
||||
Values: []event.ParamValue{
|
||||
{
|
||||
Value: approvalSubscriptionTypeInvolved,
|
||||
Desc: "Receive events where the current user is the approval requester or approver.",
|
||||
},
|
||||
{
|
||||
Value: approvalSubscriptionTypeManaged,
|
||||
Desc: "Receive events under approval definitions managed by the current user.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
ApprovalCode string `json:"approval_code"`
|
||||
InstanceCode string `json:"instance_code"`
|
||||
ExternalID string `json:"external_id"`
|
||||
Status string `json:"status"`
|
||||
OperateTime string `json:"operate_time"`
|
||||
StartUser *ApprovalUserID `json:"start_user"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
out := &ApprovalInstanceStatusChangedV4Output{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
ApprovalCode: envelope.Event.ApprovalCode,
|
||||
InstanceCode: envelope.Event.InstanceCode,
|
||||
ExternalID: envelope.Event.ExternalID,
|
||||
Status: envelope.Event.Status,
|
||||
OperateTime: envelope.Event.OperateTime,
|
||||
StartUser: envelope.Event.StartUser,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
ApprovalCode string `json:"approval_code"`
|
||||
InstanceCode string `json:"instance_code"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExternalID string `json:"external_id"`
|
||||
TaskExternalID string `json:"task_external_id"`
|
||||
AssignedUser *ApprovalUserID `json:"assigned_user"`
|
||||
Status string `json:"status"`
|
||||
OperateTime string `json:"operate_time"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
out := &ApprovalTaskStatusChangedV4Output{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
ApprovalCode: envelope.Event.ApprovalCode,
|
||||
InstanceCode: envelope.Event.InstanceCode,
|
||||
TaskID: envelope.Event.TaskID,
|
||||
ExternalID: envelope.Event.ExternalID,
|
||||
TaskExternalID: envelope.Event.TaskExternalID,
|
||||
AssignedUser: envelope.Event.AssignedUser,
|
||||
Status: envelope.Event.Status,
|
||||
OperateTime: envelope.Event.OperateTime,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
654
events/approval/register_test.go
Normal file
654
events/approval/register_test.go
Normal file
@@ -0,0 +1,654 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
type recordedCall struct {
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
}
|
||||
|
||||
type fakeAPIClient struct {
|
||||
calls []recordedCall
|
||||
err error
|
||||
errOnCall int
|
||||
}
|
||||
|
||||
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
|
||||
if f.err != nil && (f.errOnCall == 0 || f.errOnCall == len(f.calls)) {
|
||||
return nil, f.err
|
||||
}
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
|
||||
func TestKeysApprovalMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 2 {
|
||||
t.Fatalf("len(Keys()) = %d, want 2", len(keys))
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
scope string
|
||||
schemaType reflect.Type
|
||||
subscribe string
|
||||
}{
|
||||
{
|
||||
key: eventTypeApprovalInstanceStatusChangedV4,
|
||||
scope: "approval:instance:read",
|
||||
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
|
||||
subscribe: pathApprovalInstancesSubscription,
|
||||
},
|
||||
{
|
||||
key: eventTypeApprovalTaskStatusChangedV4,
|
||||
scope: "approval:task:read",
|
||||
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
|
||||
subscribe: pathApprovalTasksSubscription,
|
||||
},
|
||||
}
|
||||
|
||||
byKey := make(map[string]event.KeyDefinition, len(keys))
|
||||
for _, def := range keys {
|
||||
byKey[def.Key] = def
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
def, ok := byKey[tc.key]
|
||||
if !ok {
|
||||
t.Fatalf("missing key %s", tc.key)
|
||||
}
|
||||
if def.EventType != tc.key {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, tc.key)
|
||||
}
|
||||
if def.Schema.Custom == nil || def.Schema.Custom.Type != tc.schemaType {
|
||||
t.Fatalf("Custom schema Type = %v, want %v", def.Schema.Custom, tc.schemaType)
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Fatal("approval events must use Custom schema while SDK event types are not exported")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Fatal("Process must flatten raw V2 envelopes")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Fatal("PreConsume must subscribe approval user-auth events")
|
||||
}
|
||||
if !reflect.DeepEqual(def.Scopes, []string{tc.scope}) {
|
||||
t.Errorf("Scopes = %#v, want %q", def.Scopes, tc.scope)
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"user"}) {
|
||||
t.Errorf("AuthTypes = %#v, want user", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{tc.key}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v, want %q", def.RequiredConsoleEvents, tc.key)
|
||||
}
|
||||
assertSubscriptionParam(t, def.Params)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSubscriptionParam(t *testing.T, params []event.ParamDef) {
|
||||
t.Helper()
|
||||
if len(params) != 1 {
|
||||
t.Fatalf("len(params) = %d, want 1", len(params))
|
||||
}
|
||||
p := params[0]
|
||||
if p.Name != "subscription_type" || p.Type != event.ParamMulti || p.Required || p.SubscriptionKey {
|
||||
t.Fatalf("subscription_type param = %+v, want optional multi non-subscription-key param", p)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, v := range p.Values {
|
||||
got[v.Value] = v.Desc
|
||||
}
|
||||
for _, want := range []string{approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged} {
|
||||
if got[want] == "" {
|
||||
t.Errorf("subscription_type value %q missing or empty desc; values=%+v", want, p.Values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type reflectedApprovalSchema struct {
|
||||
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type reflectedApprovalSchemaProperty struct {
|
||||
Format string `json:"format"`
|
||||
Enum []string `json:"enum"`
|
||||
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
|
||||
}
|
||||
|
||||
func TestApprovalSchemasAnnotations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
schemaType reflect.Type
|
||||
eventType string
|
||||
statusValues []string
|
||||
userField string
|
||||
}{
|
||||
{
|
||||
name: "instance",
|
||||
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
statusValues: []string{"PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
|
||||
userField: "start_user",
|
||||
},
|
||||
{
|
||||
name: "task",
|
||||
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
statusValues: []string{"REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
|
||||
userField: "assigned_user",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var schema reflectedApprovalSchema
|
||||
if err := json.Unmarshal(schemas.FromType(tc.schemaType), &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
props := schema.Properties
|
||||
eventTypeEnum := props["type"].Enum
|
||||
if len(eventTypeEnum) != 1 || eventTypeEnum[0] != tc.eventType {
|
||||
t.Fatalf("type enum = %v, want %s", eventTypeEnum, tc.eventType)
|
||||
}
|
||||
if got := props["timestamp"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("timestamp format = %v, want timestamp_ms", got)
|
||||
}
|
||||
assertEnumContains(t, props["status"].Enum, tc.statusValues)
|
||||
if got := props["operate_time"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("event.operate_time format = %v, want timestamp_ms", got)
|
||||
}
|
||||
|
||||
userProps := props[tc.userField].Properties
|
||||
if got := userProps["open_id"].Format; got != "open_id" {
|
||||
t.Errorf("%s.open_id format = %v, want open_id", tc.userField, got)
|
||||
}
|
||||
if got := userProps["union_id"].Format; got != "union_id" {
|
||||
t.Errorf("%s.union_id format = %v, want union_id", tc.userField, got)
|
||||
}
|
||||
if got := userProps["user_id"].Format; got != "user_id" {
|
||||
t.Errorf("%s.user_id format = %v, want user_id", tc.userField, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertEnumContains(t *testing.T, raw []string, wants []string) {
|
||||
t.Helper()
|
||||
got := make(map[string]bool, len(raw))
|
||||
for _, v := range raw {
|
||||
got[v] = true
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !got[want] {
|
||||
t.Errorf("enum missing %q; enum=%v", want, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
eventType string
|
||||
subscribePath string
|
||||
params map[string]string
|
||||
wantTypes []string
|
||||
}{
|
||||
{
|
||||
name: "instance omitted subscription_type registers both",
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "task explicit single managed",
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
params: map[string]string{"subscription_type": approvalSubscriptionTypeManaged},
|
||||
wantTypes: []string{approvalSubscriptionTypeManaged},
|
||||
},
|
||||
{
|
||||
name: "task comma separated multi canonicalizes and deduplicates",
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
params: map[string]string{
|
||||
"subscription_type": approvalSubscriptionTypeManaged + "," + approvalSubscriptionTypeInvolved + "," + approvalSubscriptionTypeManaged,
|
||||
},
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "instance json array multi",
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
params: map[string]string{
|
||||
"subscription_type": `["MANAGED_APPROVAL","INVOLVED_APPROVAL"]`,
|
||||
},
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: approvalEventType(tc.eventType),
|
||||
subscribePath: approvalSubscriptionPath(tc.subscribePath),
|
||||
})
|
||||
rt := &fakeAPIClient{}
|
||||
cleanup, err := pc(context.Background(), rt, tc.params)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume returned error: %v", err)
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil; approval consume must not unsubscribe on exit")
|
||||
}
|
||||
assertSubscriptionCalls(t, rt.calls, tc.subscribePath, tc.wantTypes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSubscriptionCalls(t *testing.T, got []recordedCall, wantPath string, wantTypes []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(wantTypes) {
|
||||
t.Fatalf("calls after pre-consume = %d, want %d; calls=%+v", len(got), len(wantTypes), got)
|
||||
}
|
||||
for i, wantType := range wantTypes {
|
||||
assertCall(t, got[i], "POST", wantPath, map[string]string{"subscription_type": wantType})
|
||||
}
|
||||
}
|
||||
|
||||
func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wantBody interface{}) {
|
||||
t.Helper()
|
||||
if got.method != wantMethod {
|
||||
t.Errorf("method = %q, want %q", got.method, wantMethod)
|
||||
}
|
||||
if got.path != wantPath {
|
||||
t.Errorf("path = %q, want %q", got.path, wantPath)
|
||||
}
|
||||
if !reflect.DeepEqual(got.body, wantBody) {
|
||||
t.Errorf("body = %#v, want %#v", got.body, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalPreConsumeValidationErrors(t *testing.T) {
|
||||
t.Run("nil runtime", func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
})
|
||||
_, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved})
|
||||
if err == nil {
|
||||
t.Fatal("expected nil runtime error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryInternal {
|
||||
t.Fatalf("err = %T/%v, want typed internal error", err, err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} {
|
||||
t.Run("invalid subscription type "+raw, func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
})
|
||||
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid subscription_type error")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil on validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T/%v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want invalid_argument/--param", ve.Subtype, ve.Param)
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("invalid subscription_type should carry a hint")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) {
|
||||
upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed")
|
||||
rt := &fakeAPIClient{err: upstream, errOnCall: 2}
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
})
|
||||
|
||||
cleanup, err := pc(context.Background(), rt, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected partial registration error")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil on registration error")
|
||||
}
|
||||
assertSubscriptionCalls(t, rt.calls, pathApprovalTasksSubscription, []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"registered subscription_type(s) [INVOLVED_APPROVAL]",
|
||||
"failed subscription_type MANAGED_APPROVAL",
|
||||
} {
|
||||
if !strings.Contains(p.Message, want) {
|
||||
t.Errorf("partial error message missing %q: %q", want, p.Message)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"already registered",
|
||||
"--param subscription_type=MANAGED_APPROVAL",
|
||||
} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Errorf("partial error hint missing %q: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApprovalSubscriptionRegistrationErrorVariants(t *testing.T) {
|
||||
t.Run("nil error", func(t *testing.T) {
|
||||
if err := approvalSubscriptionRegistrationError(eventTypeApprovalTaskStatusChangedV4, nil, approvalSubscriptionTypeInvolved, nil); err != nil {
|
||||
t.Fatalf("nil cause returned error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typed error with existing hint and empty message", func(t *testing.T) {
|
||||
upstream := errs.NewAPIError(errs.SubtypeServerError, "").WithHint("retry later")
|
||||
err := approvalSubscriptionRegistrationError(
|
||||
eventTypeApprovalTaskStatusChangedV4,
|
||||
nil,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
upstream,
|
||||
)
|
||||
if err != upstream {
|
||||
t.Fatalf("typed error should be annotated in place; got %T/%v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "failed subscription_type INVOLVED_APPROVAL") {
|
||||
t.Errorf("message missing failed relation: %q", p.Message)
|
||||
}
|
||||
for _, want := range []string{"retry later", "no approval subscription relation was registered"} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Errorf("hint missing %q: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("untyped error is wrapped with retry context", func(t *testing.T) {
|
||||
cause := errors.New("transport closed")
|
||||
err := approvalSubscriptionRegistrationError(
|
||||
eventTypeApprovalTaskStatusChangedV4,
|
||||
nil,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
cause,
|
||||
)
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("wrapped error should preserve cause; got %T/%v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeSDKError {
|
||||
t.Fatalf("category/subtype = %s/%s, want internal/sdk_error", p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "no approval subscription relation was registered") {
|
||||
t.Errorf("hint missing no-registration context: %q", p.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessApprovalInstanceStatusChanged(t *testing.T) {
|
||||
out := runApprovalInstanceStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_instance_001",
|
||||
"event_type": "approval.instance.status_changed_v4",
|
||||
"create_time": "1710000000000"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_001",
|
||||
"instance_code": "instance_code_001",
|
||||
"external_id": "external_001",
|
||||
"status": "PENDING",
|
||||
"operate_time": "1666079207003",
|
||||
"start_user": {
|
||||
"open_id": "ou_start",
|
||||
"union_id": "on_start",
|
||||
"user_id": "user_start"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeApprovalInstanceStatusChangedV4 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalInstanceStatusChangedV4)
|
||||
}
|
||||
if out.EventID != "evt_approval_instance_001" || out.Timestamp != "1710000000000" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.ApprovalCode != "approval_code_001" || out.InstanceCode != "instance_code_001" {
|
||||
t.Errorf("approval/instance code = %q/%q", out.ApprovalCode, out.InstanceCode)
|
||||
}
|
||||
if out.ExternalID != "external_001" || out.Status != "PENDING" || out.OperateTime != "1666079207003" {
|
||||
t.Errorf("external/status/operate_time = %q/%q/%q", out.ExternalID, out.Status, out.OperateTime)
|
||||
}
|
||||
if out.StartUser == nil || out.StartUser.OpenID != "ou_start" || out.StartUser.UnionID != "on_start" || out.StartUser.UserID != "user_start" {
|
||||
t.Fatalf("StartUser = %+v, want full user ids", out.StartUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalTaskStatusChanged(t *testing.T) {
|
||||
out := runApprovalTaskStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_task_001",
|
||||
"event_type": "approval.task.status_changed_v4",
|
||||
"create_time": "1710000000001"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_002",
|
||||
"instance_code": "instance_code_002",
|
||||
"task_id": "task_001",
|
||||
"external_id": "external_002",
|
||||
"task_external_id": "task_external_001",
|
||||
"status": "APPROVED",
|
||||
"operate_time": "1666079207004",
|
||||
"assigned_user": {
|
||||
"open_id": "ou_assignee",
|
||||
"union_id": "on_assignee",
|
||||
"user_id": "user_assignee"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeApprovalTaskStatusChangedV4 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalTaskStatusChangedV4)
|
||||
}
|
||||
if out.EventID != "evt_approval_task_001" || out.Timestamp != "1710000000001" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.ApprovalCode != "approval_code_002" || out.InstanceCode != "instance_code_002" || out.TaskID != "task_001" {
|
||||
t.Errorf("approval/instance/task = %q/%q/%q", out.ApprovalCode, out.InstanceCode, out.TaskID)
|
||||
}
|
||||
if out.ExternalID != "external_002" || out.TaskExternalID != "task_external_001" || out.Status != "APPROVED" || out.OperateTime != "1666079207004" {
|
||||
t.Errorf("external/task_external/status/operate_time = %q/%q/%q/%q", out.ExternalID, out.TaskExternalID, out.Status, out.OperateTime)
|
||||
}
|
||||
if out.AssignedUser == nil || out.AssignedUser.OpenID != "ou_assignee" || out.AssignedUser.UnionID != "on_assignee" || out.AssignedUser.UserID != "user_assignee" {
|
||||
t.Fatalf("AssignedUser = %+v, want full user ids", out.AssignedUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) {
|
||||
instance := runApprovalInstanceStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_instance_fallback",
|
||||
"create_time": "1710000000002"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_fallback",
|
||||
"instance_code": "instance_code_fallback",
|
||||
"status": "APPROVED",
|
||||
"operate_time": "1666079207005"
|
||||
}
|
||||
}`)
|
||||
if instance.Type != eventTypeApprovalInstanceStatusChangedV4 {
|
||||
t.Errorf("instance Type fallback = %q, want %q", instance.Type, eventTypeApprovalInstanceStatusChangedV4)
|
||||
}
|
||||
|
||||
task := runApprovalTaskStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_task_fallback",
|
||||
"create_time": "1710000000003"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_fallback",
|
||||
"instance_code": "instance_code_fallback",
|
||||
"task_id": "task_fallback",
|
||||
"status": "DONE",
|
||||
"operate_time": "1666079207006"
|
||||
}
|
||||
}`)
|
||||
if task.Type != eventTypeApprovalTaskStatusChangedV4 {
|
||||
t.Errorf("task Type fallback = %q, want %q", task.Type, eventTypeApprovalTaskStatusChangedV4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"instance", eventTypeApprovalInstanceStatusChangedV4, processApprovalInstanceStatusChanged},
|
||||
{"task", eventTypeApprovalTaskStatusChangedV4, processApprovalTaskStatusChanged},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: tc.eventType,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := tc.process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedNilRaw(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"instance", processApprovalInstanceStatusChanged},
|
||||
{"task", processApprovalTaskStatusChanged},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := tc.process(context.Background(), nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process nil raw returned error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("Process nil raw output = %s, want nil", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process returned error: %v", err)
|
||||
}
|
||||
var out ApprovalInstanceStatusChangedV4Output
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid instance JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStatusChangedV4Output {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process returned error: %v", err)
|
||||
}
|
||||
var out ApprovalTaskStatusChangedV4Output
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid task JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestApprovalKeysRegisterCleanly(t *testing.T) {
|
||||
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
}
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ event.APIClient = (*fakeAPIClient)(nil)
|
||||
42
events/approval/types.go
Normal file
42
events/approval/types.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
// ApprovalUserID identifies a user in the three Lark ID formats included by
|
||||
// approval status-change events.
|
||||
type ApprovalUserID struct {
|
||||
OpenID string `json:"open_id,omitempty" desc:"User open_id; prefixed with ou_" kind:"open_id"`
|
||||
UnionID string `json:"union_id,omitempty" desc:"User union_id" kind:"union_id"`
|
||||
UserID string `json:"user_id,omitempty" desc:"User id within the tenant" kind:"user_id"`
|
||||
}
|
||||
|
||||
// ApprovalInstanceStatusChangedV4Output is the flattened shape for
|
||||
// approval.instance.status_changed_v4.
|
||||
type ApprovalInstanceStatusChangedV4Output struct {
|
||||
Type string `json:"type" desc:"Event type; always approval.instance.status_changed_v4" enum:"approval.instance.status_changed_v4"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
|
||||
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
|
||||
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval instance id; present only for third-party approvals"`
|
||||
Status string `json:"status,omitempty" desc:"Approval instance status" enum:"PENDING,APPROVED,REJECTED,CANCELED,DELETED,REVERTED,OVERTIME_CLOSE,OVERTIME_RECOVER"`
|
||||
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
|
||||
StartUser *ApprovalUserID `json:"start_user,omitempty" desc:"Approval instance starter; omitted when unavailable"`
|
||||
}
|
||||
|
||||
// ApprovalTaskStatusChangedV4Output is the flattened shape for
|
||||
// approval.task.status_changed_v4.
|
||||
type ApprovalTaskStatusChangedV4Output struct {
|
||||
Type string `json:"type" desc:"Event type; always approval.task.status_changed_v4" enum:"approval.task.status_changed_v4"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
|
||||
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
|
||||
TaskID string `json:"task_id,omitempty" desc:"Approval task id"`
|
||||
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval external id; present only for third-party approvals"`
|
||||
TaskExternalID string `json:"task_external_id,omitempty" desc:"Third-party approval task external id; present only when emitted by the upstream service"`
|
||||
AssignedUser *ApprovalUserID `json:"assigned_user,omitempty" desc:"Task assignee or operator user ids; omitted for automatic flows without an operator"`
|
||||
Status string `json:"status,omitempty" desc:"Approval task status" enum:"REVERTED,PENDING,APPROVED,REJECTED,TRANSFERRED,ROLLBACK,DONE,OVERTIME_CLOSE,OVERTIME_RECOVER"`
|
||||
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/application"
|
||||
"github.com/larksuite/cli/events/approval"
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/task"
|
||||
@@ -16,6 +18,8 @@ import (
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
application.Keys(),
|
||||
approval.Keys(),
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
task.Keys(),
|
||||
|
||||
@@ -37,21 +37,79 @@ Wire into a fork:
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
_ "github.com/me/myplugin" // blank import → init() runs
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() { os.Exit(cmd.Execute()) }
|
||||
func main() {
|
||||
os.Exit(cmd.Execute())
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
go build -o larkx ./cmd/larkx && ./larkx config plugins show
|
||||
go build -o lark-cli ./cmd/larkx && ./lark-cli config plugins show
|
||||
```
|
||||
|
||||
You should see `audit` in the plugin list.
|
||||
|
||||
That is sufficient for a hook-only plugin such as the audit observer. A
|
||||
wrapper main does not compile lark-cli's repository-root `content_embed.go`,
|
||||
so distribution content is a separate, explicit host choice.
|
||||
|
||||
### Ship skills and command guidance
|
||||
|
||||
If the distribution exposes embedded skills or customizes them with
|
||||
`EmbeddedSkills`, copy or generate both content trees under the wrapper
|
||||
package and wire both:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
_ "github.com/me/myplugin"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
)
|
||||
|
||||
//go:embed skills affordance
|
||||
var distributionContent embed.FS
|
||||
|
||||
func main() {
|
||||
skillTree, err := fs.Sub(distributionContent, "skills")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
affordanceTree, err := fs.Sub(distributionContent, "affordance")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cmd.SetEmbeddedSkillContent(skillTree)
|
||||
cmd.SetEmbeddedAffordanceContent(affordanceTree)
|
||||
os.Exit(cmd.Execute())
|
||||
}
|
||||
```
|
||||
|
||||
`go:embed` only reads files in the package being compiled; it cannot reach
|
||||
into the replaced `github.com/larksuite/cli` module. Each
|
||||
`skills/<name>/` must contain `SKILL.md`. The `affordance/*.md` files are the
|
||||
structured source for command help and canonical skill references; ship the
|
||||
ones for the domains your distribution retains. Without
|
||||
`SetEmbeddedSkillContent`, `skills list` has no base content and an `Allow` or
|
||||
`Remove` overlay deliberately aborts startup. A plugin may instead provide a
|
||||
complete `SkillsOverlay.Base`. Without `SetEmbeddedAffordanceContent`,
|
||||
commands still run, but distribution-specific guidance and its skill pointers
|
||||
are absent.
|
||||
|
||||
Keep the executable available as `lark-cli` on `PATH`: command-linked
|
||||
guidance invokes that canonical name.
|
||||
|
||||
## What you can hook
|
||||
|
||||
| Hook | Fires | Can block? |
|
||||
@@ -60,6 +118,7 @@ You should see `audit` in the plugin list.
|
||||
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
|
||||
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
|
||||
| `Restrict(Rule)` | Bootstrap-time, ≥1 per plugin | Denies whole subtrees |
|
||||
| `EmbeddedSkills(SkillsOverlay)` | Bootstrap-time, ≤1 per plugin | Build-integrity (fail-closed) |
|
||||
|
||||
### Plugin lifecycle
|
||||
|
||||
@@ -79,7 +138,7 @@ sequenceDiagram
|
||||
Host->>SDK: InstallAll()
|
||||
SDK->>Plugin: Capabilities()
|
||||
SDK->>Plugin: Install(Registrar)
|
||||
Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown)
|
||||
Plugin->>SDK: Observe / Wrap / Restrict / EmbeddedSkills / On(Startup,Shutdown)
|
||||
SDK->>Plugin: On(Startup) fire
|
||||
|
||||
Note over Host,Plugin: Each command dispatch
|
||||
@@ -93,9 +152,8 @@ sequenceDiagram
|
||||
SDK->>Plugin: On(Shutdown) fire
|
||||
```
|
||||
|
||||
A `command_denied` decision (from `Restrict` or strict-mode) bypasses
|
||||
the `Wrap` chain entirely — observers still fire so audit plugins see
|
||||
the rejected dispatch.
|
||||
A rule or strict-mode denial bypasses the `Wrap` chain entirely —
|
||||
observers still fire so audit plugins see the rejected dispatch.
|
||||
|
||||
## Safety contract (read this)
|
||||
|
||||
@@ -113,10 +171,79 @@ the rejected dispatch.
|
||||
widen another's policy). YAML policy at `~/.lark-cli/policy.yml` (which
|
||||
may itself list several rules under `rules:`) is shadowed by any plugin
|
||||
Restrict.
|
||||
- A plugin may call `EmbeddedSkills()` at most once to customize the embedded
|
||||
skill tree — `Allow` keeps only the listed skills (the allow-list
|
||||
counterpart of `Rule.Allow`, so a CLI upgrade cannot widen the build;
|
||||
`Remove` wins over `Allow`, and `Overlay` entries are exempt), `Remove`
|
||||
drops skills, `Overlay` adds/replaces ones, or swap the whole `Base` —
|
||||
layered over the host-provided base skill tree. The repository's root
|
||||
lark-cli binary wires its default in `content_embed.go`; an external fork
|
||||
main must call `cmd.SetEmbeddedSkillContent` as shown above (unless its
|
||||
plugin supplies `Base`) and should wire `cmd.SetEmbeddedAffordanceContent`
|
||||
for command guidance. `EmbeddedSkills()` implies `FailClosed`: it
|
||||
declares distribution assets, and silently falling back could republish
|
||||
content the distribution explicitly removed or replaced. Removing a skill
|
||||
drops its `skills read` content and every framework-owned structured help
|
||||
block that depends on it; it does NOT disable matching commands (use
|
||||
`Restrict()` for that). The inverse is also explicit: concealing a command
|
||||
does not automatically delete its skill content. Command policy and
|
||||
distribution assets are independent axes; use `EmbeddedSkills` when both
|
||||
must be trimmed.
|
||||
`ReferenceRemaps` can rename a whole referenced skill while preserving
|
||||
relative paths, or override one exact reference:
|
||||
|
||||
```go
|
||||
EmbeddedSkills(&platform.SkillsOverlay{
|
||||
Base: customizedSkills,
|
||||
ReferenceRemaps: []platform.SkillRefRemap{
|
||||
platform.RemapSkillRef("lark-doc", "acme-docx"),
|
||||
platform.RemapSkillRef(
|
||||
"lark-doc/references/lark-doc-fetch.md",
|
||||
"acme-docx/guides/fetch.md",
|
||||
),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Remaps apply only to structured CLI help/affordance references; they never
|
||||
scan or rewrite arbitrary prose or links inside Skill Markdown. An explicit
|
||||
remap to a missing target aborts startup, while an unmapped canonical
|
||||
reference removed from the final tree causes its complete dependent help
|
||||
block to be omitted. Only ONE plugin per binary may
|
||||
contribute a `SkillsOverlay`; two DISTINCT plugins is a deliberate
|
||||
`multiple_skills_overlay_plugins` error. The top-level skill set and
|
||||
each skill's owning FS are snapshotted during CLI build; files inside an
|
||||
owned skill directory remain live. Both `Base` and `Overlay` must
|
||||
contain only valid skill directories with `SKILL.md`.
|
||||
- A command denied by a Rule is hidden from normal command discovery and
|
||||
returns `validation/failed_precondition` with its policy source, rule, and
|
||||
reason code in the recovery hint. This is the established `Restrict`
|
||||
contract for both plugin and yaml sources. A distribution that wants
|
||||
plugin-restricted commands to look absent must opt in from its wrapper main
|
||||
with `cmd.ExecuteWithOptions(cmd.ConcealRestrictedCommands(...))`;
|
||||
presentation is a host choice, not part of `Rule` or `Capabilities`. One
|
||||
carve-out: a command already retired by the user's strict-mode setting
|
||||
keeps its strict-mode identity error even when a plugin Rule also
|
||||
matches it — strict-mode is a user-side security boundary and is never
|
||||
re-labelled.
|
||||
A wrapper may customize the absent-capability message:
|
||||
|
||||
```go
|
||||
os.Exit(cmd.ExecuteWithOptions(
|
||||
cmd.ConcealRestrictedCommands(
|
||||
cmd.UnavailableMessage("capability not shipped by this distribution"),
|
||||
),
|
||||
))
|
||||
```
|
||||
- `config policy show` / `config plugins show` stay executable under any
|
||||
plugin policy (hidden from help when their domain is denied) so an
|
||||
operator can still inspect the rule that locked the build. A concealed
|
||||
distribution can remove those escape hatches with the host-side
|
||||
`cmd.HidePolicyDiagnostics()` presentation option.
|
||||
- The `Wrap` factory runs **once per command dispatch**, not at
|
||||
install time. Long-lived state (clients, caches, metrics counters)
|
||||
must live on the Plugin struct or in package-level variables.
|
||||
- Plugins cannot suppress a `command_denied`: the framework
|
||||
- Plugins cannot suppress a denied dispatch: the framework
|
||||
physically isolates denied commands from the Wrap chain (Observers
|
||||
still fire).
|
||||
- Commands missing a `risk_level` annotation are denied by default
|
||||
@@ -131,13 +258,20 @@ the rejected dispatch.
|
||||
|
||||
## reason_code reference
|
||||
|
||||
Every install / dispatch failure emits a `command_denied` or
|
||||
`plugin_install` envelope carrying a `detail.reason_code` from the
|
||||
closed enum below. Use the code (not the human-readable message) when
|
||||
matching errors in agents, CI scripts, or downstream tools — the
|
||||
messages are localised and may change between releases.
|
||||
Install and rule evaluation keep a closed `reason_code` taxonomy for
|
||||
operator diagnostics and in-process errors. The established Restrict
|
||||
presentation includes the reason code in the error hint. A distribution
|
||||
that explicitly enables command concealment replaces that wire presentation
|
||||
with `validation/command_unavailable`.
|
||||
|
||||
### Plugin install (`error.type = plugin_install`)
|
||||
### Plugin installation/configuration diagnostics
|
||||
|
||||
Fail-closed bootstrap errors that reach the CLI dispatcher use
|
||||
`error.type=validation` and `error.subtype=failed_precondition`. The
|
||||
diagnostic `reason_code` values below currently appear in the human-readable
|
||||
hint; they are not a separate `detail` field. In-process hosts should inspect
|
||||
the wrapped platform error with `errors.As` / `errors.Is` when they need the
|
||||
precise cause.
|
||||
|
||||
| reason_code | When it fires | Honours FailurePolicy? |
|
||||
| --------------------------- | ------------------------------------------------------------------------------ | ---------------------- |
|
||||
@@ -145,7 +279,7 @@ messages are localised and may change between releases.
|
||||
| `plugin_name_panic` | `Plugin.Name()` panicked | No — always aborts |
|
||||
| `duplicate_plugin_name` | Two plugins return the same `Name()` | No — always aborts |
|
||||
| `capabilities_panic` | `Plugin.Capabilities()` panicked | Yes |
|
||||
| `invalid_capability` | `Capabilities` malformed: bad `RequiredCLIVersion`, unknown `FailurePolicy` | No — always aborts |
|
||||
| `invalid_capability` | `Capabilities` malformed: bad version/policy, or `EmbeddedSkills` contributed under `FailOpen` | No — always aborts |
|
||||
| `capability_unmet` | Current CLI version doesn't satisfy `RequiredCLIVersion` | Yes |
|
||||
| `restricts_mismatch` | `Restricts=true` without `FailClosed`, or `Restricts` flag inconsistent w/ Install | No — always aborts |
|
||||
| `invalid_hook_name` | Hook name contains `.` or doesn't match the plugin namespace | Yes |
|
||||
@@ -153,6 +287,8 @@ messages are localised and may change between releases.
|
||||
| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
|
||||
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
|
||||
| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes |
|
||||
| `invalid_skills_overlay` | Registration fault (`nil` / duplicate call), or invalid selection/content/reference remap | Registration honours policy; composition always aborts |
|
||||
| `multiple_skills_overlay_plugins` | Two or more DISTINCT plugins each contributed a `SkillsOverlay` (only one may own skill content) | No — always aborts (dispatch guard) |
|
||||
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
|
||||
| `install_panic` | `Plugin.Install` panicked | Yes |
|
||||
|
||||
@@ -161,7 +297,7 @@ the host can't honour the plugin's declared `FailurePolicy` because the
|
||||
declaration itself is suspect (e.g. an `invalid_capability` plugin
|
||||
might also be lying about being `FailOpen`).
|
||||
|
||||
### Command dispatch (`error.type = command_denied`)
|
||||
### Command rule evaluation (internal/operator diagnostics)
|
||||
|
||||
| reason_code | Meaning |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -175,20 +311,20 @@ might also be lying about being `FailOpen`).
|
||||
| `no_matching_rule` | Several rules are active and the command satisfied none of them (the message summarises each rule's own rejection). Single-rule policies keep their specific reason_code instead |
|
||||
| `aggregate_all_denied` | Aggregate stub installed on a parent group because every live child was denied |
|
||||
|
||||
The `detail.layer` field distinguishes who rejected the call:
|
||||
`policy` (this SDK's user-layer engine) vs. `strict_mode`
|
||||
(`cmd/prune.go`'s credential-hardening pass). Agents that want to
|
||||
dispatch on "any denial" should match `error.type == "command_denied"`
|
||||
and ignore the layer; agents that only care about user-policy denials
|
||||
should additionally check `detail.layer == "policy"`.
|
||||
These codes remain available to in-process hosts through the wrapped
|
||||
`*platform.CommandDeniedError` cause. Operator commands expose the active
|
||||
rule and shipped-tree summary. Agents consuming a host that explicitly
|
||||
enabled concealment should match `error.type == "validation"` and
|
||||
`error.subtype == "command_unavailable"` instead of branching on a
|
||||
rule-specific reason.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Runnable example: audit observer](./examples/audit-observer/)
|
||||
- [Runnable example: read-only policy](./examples/readonly-policy/)
|
||||
- Builder API: see [`builder.go`](./builder.go) for the full DSL
|
||||
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `FailOpen`/`FailClosed`,
|
||||
`MustBuild`).
|
||||
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `EmbeddedSkills`,
|
||||
`FailOpen`/`FailClosed`, `MustBuild`).
|
||||
- Inventory diagnostic: run `lark-cli config plugins show` after
|
||||
installing your plugin to see hooks/rules attributed to your plugin
|
||||
name.
|
||||
|
||||
@@ -28,6 +28,9 @@ import (
|
||||
// - Restricts ↔ FailClosed consistency (calling Restrict() implies
|
||||
// FailClosed, so plugin authors cannot accidentally ship a policy
|
||||
// plugin under FailOpen)
|
||||
// - EmbeddedSkills ↔ FailClosed consistency (declaring distribution
|
||||
// assets is a build-integrity commitment; falling back to host defaults
|
||||
// is never allowed)
|
||||
// - Rule validation via ValidateRule analogues (delegated to
|
||||
// internal/cmdpolicy at install time; Builder only fast-fails
|
||||
// blatantly bad input)
|
||||
@@ -36,8 +39,9 @@ type Builder struct {
|
||||
version string
|
||||
caps Capabilities
|
||||
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
skillsOverlay *SkillsOverlay
|
||||
|
||||
hookNames map[string]bool
|
||||
errs []error
|
||||
@@ -68,14 +72,16 @@ func (b *Builder) RequireCLI(constraint string) *Builder {
|
||||
}
|
||||
|
||||
// FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when
|
||||
// neither FailOpen nor FailClosed is called and Restrict is not used.
|
||||
// neither FailOpen nor FailClosed is called and neither Restrict nor
|
||||
// EmbeddedSkills is used. Build rejects a final FailOpen state after either
|
||||
// safety-sensitive contribution.
|
||||
func (b *Builder) FailOpen() *Builder {
|
||||
b.caps.FailurePolicy = FailOpen
|
||||
return b
|
||||
}
|
||||
|
||||
// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit
|
||||
// when Restrict() is called.
|
||||
// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit when
|
||||
// Restrict() or EmbeddedSkills() is called.
|
||||
func (b *Builder) FailClosed() *Builder {
|
||||
b.caps.FailurePolicy = FailClosed
|
||||
return b
|
||||
@@ -145,25 +151,68 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
|
||||
return b
|
||||
}
|
||||
|
||||
// EmbeddedSkills contributes a SkillsOverlay (see SkillsOverlay) customizing
|
||||
// the CLI's embedded skill content. It implies FailClosed: although skill
|
||||
// content is not a command-enforcement boundary, the overlay is a distribution
|
||||
// build-integrity declaration. Silently skipping it could republish host
|
||||
// defaults that the distribution explicitly removed or replaced.
|
||||
//
|
||||
// Calling FailOpen before EmbeddedSkills is allowed; EmbeddedSkills overrides
|
||||
// it to FailClosed, matching Restrict. Calling FailOpen afterward leaves an
|
||||
// invalid final state that Build rejects. A later FailClosed restores a valid
|
||||
// final state. A plugin owns at most one SkillsOverlay, so calling
|
||||
// EmbeddedSkills more than once is a build error.
|
||||
func (b *Builder) EmbeddedSkills(spec *SkillsOverlay) *Builder {
|
||||
if spec == nil {
|
||||
b.errs = append(b.errs, errors.New("EmbeddedSkills(nil): spec must not be nil"))
|
||||
return b
|
||||
}
|
||||
if b.skillsOverlay != nil {
|
||||
b.errs = append(b.errs, errors.New("EmbeddedSkills() called more than once; a plugin owns at most one SkillsOverlay"))
|
||||
return b
|
||||
}
|
||||
b.caps.FailurePolicy = FailClosed
|
||||
b.skillsOverlay = cloneSkillsOverlay(spec)
|
||||
return b
|
||||
}
|
||||
|
||||
// cloneSkillsOverlay snapshots the caller's spec so a later mutation of the
|
||||
// same *SkillsOverlay cannot alter the staged copy. Selection and remap slices
|
||||
// are copied; Overlay/Base are fs.FS handles retained by reference (an fs.FS is
|
||||
// a read-only view, not caller-mutable state).
|
||||
func cloneSkillsOverlay(spec *SkillsOverlay) *SkillsOverlay {
|
||||
cp := *spec
|
||||
cp.Allow = append([]string(nil), spec.Allow...)
|
||||
cp.Remove = append([]string(nil), spec.Remove...)
|
||||
cp.ReferenceRemaps = append([]SkillRefRemap(nil), spec.ReferenceRemaps...)
|
||||
return &cp
|
||||
}
|
||||
|
||||
// Build returns the configured Plugin, or an error if any builder
|
||||
// step found a fault. MustBuild panics on the same error.
|
||||
//
|
||||
// The Restrict + FailOpen mismatch is checked here, not in the chained
|
||||
// setters, because the two methods may be called in either order.
|
||||
// FailOpen mismatches are checked against the final builder state, not in the
|
||||
// chained setters, because FailOpen/FailClosed and the contributing methods may
|
||||
// be called in either order.
|
||||
func (b *Builder) Build() (Plugin, error) {
|
||||
if len(b.rules) > 0 && b.caps.FailurePolicy == FailOpen {
|
||||
b.errs = append(b.errs, errors.New(
|
||||
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
|
||||
}
|
||||
if b.skillsOverlay != nil && b.caps.FailurePolicy == FailOpen {
|
||||
b.errs = append(b.errs, errors.New(
|
||||
"EmbeddedSkills() requires FailClosed; do not call FailOpen() after EmbeddedSkills()"))
|
||||
}
|
||||
if len(b.errs) > 0 {
|
||||
return nil, errors.Join(b.errs...)
|
||||
}
|
||||
return &builtPlugin{
|
||||
name: b.name,
|
||||
version: b.version,
|
||||
caps: b.caps,
|
||||
actions: b.actions,
|
||||
rules: b.rules,
|
||||
name: b.name,
|
||||
version: b.version,
|
||||
caps: b.caps,
|
||||
actions: b.actions,
|
||||
rules: b.rules,
|
||||
skillsOverlay: b.skillsOverlay,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -202,11 +251,12 @@ func (b *Builder) validateHookName(hookName, kind string) bool {
|
||||
|
||||
// builtPlugin is the Plugin implementation the builder emits.
|
||||
type builtPlugin struct {
|
||||
name string
|
||||
version string
|
||||
caps Capabilities
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
name string
|
||||
version string
|
||||
caps Capabilities
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
skillsOverlay *SkillsOverlay
|
||||
}
|
||||
|
||||
func (p *builtPlugin) Name() string { return p.name }
|
||||
@@ -216,6 +266,15 @@ func (p *builtPlugin) Install(r Registrar) error {
|
||||
for _, rule := range p.rules {
|
||||
r.Restrict(rule)
|
||||
}
|
||||
if p.skillsOverlay != nil {
|
||||
sr, ok := r.(EmbeddedSkillsRegistrar)
|
||||
if !ok {
|
||||
// Fail closed: a declared skill customization must never be
|
||||
// silently dropped by a host that cannot honour it.
|
||||
return errors.New("host registrar does not support EmbeddedSkills")
|
||||
}
|
||||
sr.EmbeddedSkills(p.skillsOverlay)
|
||||
}
|
||||
for _, action := range p.actions {
|
||||
action(r)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ import (
|
||||
// recorder Registrar captures everything a builder schedules so the
|
||||
// test can assert what Install produced without involving the host.
|
||||
type recorder struct {
|
||||
observers int
|
||||
wrappers int
|
||||
lifecycles int
|
||||
rule *platform.Rule // last rule (existing single-rule assertions)
|
||||
rules []*platform.Rule // every rule, in Restrict order
|
||||
observers int
|
||||
wrappers int
|
||||
lifecycles int
|
||||
rule *platform.Rule // last rule (existing single-rule assertions)
|
||||
rules []*platform.Rule // every rule, in Restrict order
|
||||
skillsOverlay *platform.SkillsOverlay
|
||||
skillCalls int
|
||||
}
|
||||
|
||||
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
|
||||
@@ -30,6 +32,10 @@ func (r *recorder) Restrict(rule *platform.Rule) {
|
||||
r.rule = rule
|
||||
r.rules = append(r.rules, rule)
|
||||
}
|
||||
func (r *recorder) EmbeddedSkills(spec *platform.SkillsOverlay) {
|
||||
r.skillsOverlay = spec
|
||||
r.skillCalls++
|
||||
}
|
||||
|
||||
// Restrict must snapshot each rule: a caller that reuses and mutates the
|
||||
// same *Rule object across two Restrict calls must still get two distinct
|
||||
@@ -211,3 +217,124 @@ func TestBuilder_failOpenThenRestrictOK(t *testing.T) {
|
||||
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// EmbeddedSkills() must snapshot selection and remap slices: a caller that
|
||||
// mutates the same backing arrays after the call must still get the values
|
||||
// staged at call time.
|
||||
func TestBuilder_skillsInstalledAndCloned(t *testing.T) {
|
||||
remove := []string{"lark-shared"}
|
||||
remaps := []platform.SkillRefRemap{
|
||||
platform.RemapSkillRef("lark-doc", "acme-docx"),
|
||||
}
|
||||
spec := &platform.SkillsOverlay{Remove: remove, ReferenceRemaps: remaps}
|
||||
b := platform.NewPlugin("p", "0").EmbeddedSkills(spec)
|
||||
remove[0] = "mutated"
|
||||
remaps[0] = platform.RemapSkillRef("lark-doc", "mutated")
|
||||
spec.ReferenceRemaps = nil
|
||||
|
||||
p, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
r := &recorder{}
|
||||
if err := p.Install(r); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if r.skillCalls != 1 {
|
||||
t.Fatalf("Skills calls = %d, want 1", r.skillCalls)
|
||||
}
|
||||
if r.skillsOverlay == nil || len(r.skillsOverlay.Remove) != 1 || r.skillsOverlay.Remove[0] != "lark-shared" {
|
||||
t.Errorf("staged Remove leaked later mutation: %+v", r.skillsOverlay)
|
||||
}
|
||||
if len(r.skillsOverlay.ReferenceRemaps) != 1 {
|
||||
t.Fatalf("staged ReferenceRemaps = %+v, want one mapping", r.skillsOverlay.ReferenceRemaps)
|
||||
}
|
||||
remap := r.skillsOverlay.ReferenceRemaps[0]
|
||||
if remap.From() != "lark-doc" || remap.To() != "acme-docx" {
|
||||
t.Errorf("staged remap = %q -> %q, want lark-doc -> acme-docx", remap.From(), remap.To())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_skillsNilRejected(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").EmbeddedSkills(nil).Build()
|
||||
if err == nil {
|
||||
t.Fatal("EmbeddedSkills(nil) must produce error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_skillsTwiceRejected(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}}).
|
||||
Build()
|
||||
if err == nil {
|
||||
t.Fatal("calling EmbeddedSkills() twice must produce error")
|
||||
}
|
||||
}
|
||||
|
||||
// EmbeddedSkills is a distribution build-integrity commitment: skipping it
|
||||
// could silently restore content that the distribution intended to remove.
|
||||
func TestBuilder_skillsForcesFailClosed(t *testing.T) {
|
||||
p, err := platform.NewPlugin("p", "0").EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
caps := p.Capabilities()
|
||||
if caps.Restricts {
|
||||
t.Error("EmbeddedSkills() must not set Restricts")
|
||||
}
|
||||
if caps.FailurePolicy != platform.FailClosed {
|
||||
t.Errorf("FailurePolicy = %v, want FailClosed", caps.FailurePolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_skillsFailurePolicyOrder(t *testing.T) {
|
||||
spec := func() *platform.SkillsOverlay {
|
||||
return &platform.SkillsOverlay{Remove: []string{"lark-a"}}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
build func() *platform.Builder
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "FailOpen before EmbeddedSkills is overridden",
|
||||
build: func() *platform.Builder {
|
||||
return platform.NewPlugin("p", "0").FailOpen().EmbeddedSkills(spec())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "FailOpen after EmbeddedSkills is invalid",
|
||||
build: func() *platform.Builder {
|
||||
return platform.NewPlugin("p", "0").EmbeddedSkills(spec()).FailOpen()
|
||||
},
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "later FailClosed restores a valid final state",
|
||||
build: func() *platform.Builder {
|
||||
return platform.NewPlugin("p", "0").EmbeddedSkills(spec()).FailOpen().FailClosed()
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p, err := tc.build().Build()
|
||||
if tc.wantError {
|
||||
if err == nil {
|
||||
t.Fatal("Build succeeded, want FailOpen+EmbeddedSkills rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "EmbeddedSkills() requires FailClosed") {
|
||||
t.Fatalf("error = %v, want EmbeddedSkills FailClosed guidance", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
if got := p.Capabilities().FailurePolicy; got != platform.FailClosed {
|
||||
t.Fatalf("FailurePolicy = %v, want FailClosed", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@ const (
|
||||
// where missing audit data is preferable to a broken CLI.
|
||||
FailOpen FailurePolicy = iota
|
||||
|
||||
// FailClosed — abort the entire CLI startup. Required for any
|
||||
// plugin that contributes Restrict() (a missing policy plugin =
|
||||
// missing security boundary) or that owns any safety-sensitive
|
||||
// concern. Enforced by the framework: Capabilities.Restricts=true
|
||||
// must pair with FailurePolicy=FailClosed.
|
||||
// FailClosed — abort the entire CLI startup. Required for any plugin
|
||||
// that contributes Restrict() (a missing policy plugin = missing
|
||||
// security boundary), EmbeddedSkills() (silently dropping distribution
|
||||
// assets would violate build integrity), or any other safety-sensitive
|
||||
// concern. The Builder sets it automatically for Restrict and
|
||||
// EmbeddedSkills; the host validates hand-written plugins after staging.
|
||||
FailClosed
|
||||
)
|
||||
|
||||
@@ -45,6 +46,6 @@ type Capabilities struct {
|
||||
|
||||
// FailurePolicy decides what happens on install failure. See the
|
||||
// constants above; the framework requires FailClosed whenever
|
||||
// Restricts=true.
|
||||
// Restricts=true or Install contributes EmbeddedSkills.
|
||||
FailurePolicy FailurePolicy
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
// gives a comparable rank for the read < write < high-risk-write ordering
|
||||
// - CommandDeniedError - structured error returned to denied callers
|
||||
//
|
||||
// Stability: every exported symbol here is part of the contract. Internal
|
||||
// Stability: every exported symbol here is part of the contract. Interfaces
|
||||
// never gain methods; new host capability surfaces arrive as optional
|
||||
// extension interfaces (see EmbeddedSkillsRegistrar). Internal
|
||||
// orchestration (staging, validation, RunE wrapping, denial guard) lives
|
||||
// under internal/platform, internal/hook and internal/cmdpolicy and is not
|
||||
// importable by third parties.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Example: read-only policy
|
||||
|
||||
A policy plugin that installs a `Rule` allowing only `docs/*` and
|
||||
`im/*` read commands. Any write command produces a structured
|
||||
`command_denied` envelope.
|
||||
`im/*` read commands. Any denied command produces a structured
|
||||
`failed_precondition` envelope with policy diagnostics.
|
||||
|
||||
## Build & run
|
||||
|
||||
@@ -15,26 +15,19 @@ go build -o readonly-cli .
|
||||
# "source": "plugin",
|
||||
# "source_name": "readonly",
|
||||
# "denied_paths": N,
|
||||
# "rule": {
|
||||
# "rules": [{
|
||||
# "name": "agent-readonly",
|
||||
# "allow": ["docs/**", "im/**"],
|
||||
# "deny": [],
|
||||
# "max_risk": "read",
|
||||
# "identities": [],
|
||||
# "allow_unannotated": false
|
||||
# }
|
||||
# }]
|
||||
# }
|
||||
|
||||
./readonly-cli docs +update --doc-token X --content Y
|
||||
# {"ok":false,"error":{
|
||||
# "type":"command_denied",
|
||||
# "detail":{
|
||||
# "layer":"policy",
|
||||
# "policy_source":"plugin:readonly",
|
||||
# "rule_name":"agent-readonly",
|
||||
# "reason_code":"write_not_allowed"
|
||||
# }
|
||||
# }}
|
||||
./readonly-cli docs +update --doc X --content Y
|
||||
# {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
|
||||
# "hint":"denied by policy policy (source plugin:readonly, ... reason_code write_not_allowed); ..."}}
|
||||
|
||||
./readonly-cli docs +fetch --doc-token X
|
||||
# Normal read response (assuming credentials)
|
||||
@@ -51,6 +44,8 @@ go build -o readonly-cli .
|
||||
- `AllowUnannotated` is left default (false): unannotated commands
|
||||
are denied with `risk_not_annotated`. Set it to true if you need
|
||||
a gradual-adoption window for the lark-cli main tree.
|
||||
- A fork that wants denied commands to present as absent can opt in from
|
||||
`main` with `cmd.ExecuteWithOptions(cmd.ConcealRestrictedCommands(...))`.
|
||||
|
||||
## Caveats
|
||||
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
|
||||
// Command readonly-policy is a runnable fork of lark-cli that
|
||||
// installs a Rule permitting only docs/* and im/* read commands.
|
||||
// Any write command produces a structured command_denied envelope.
|
||||
// Any write command is rejected with the established Restrict policy
|
||||
// envelope.
|
||||
//
|
||||
// Build & run:
|
||||
//
|
||||
// cd extension/platform/examples/readonly-policy
|
||||
// go build -o readonly-cli .
|
||||
// ./readonly-cli docs +update --doc-token X --content Y
|
||||
// # {"ok":false,"error":{"type":"command_denied", ...}}
|
||||
// # {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",...}}
|
||||
//
|
||||
// ./readonly-cli config policy show
|
||||
// # shows the active Rule with source=plugin:readonly
|
||||
|
||||
@@ -36,3 +36,24 @@ type Registrar interface {
|
||||
// plugins both calling Restrict abort startup.
|
||||
Restrict(r *Rule)
|
||||
}
|
||||
|
||||
// EmbeddedSkillsRegistrar is the optional extension a host registrar
|
||||
// implements to accept embedded-skill customization. It is deliberately NOT
|
||||
// part of Registrar: every exported symbol in this package is a stability
|
||||
// contract, and widening Registrar would break existing third-party
|
||||
// implementations (fakes, decorators, custom hosts). A Builder-built plugin
|
||||
// type-asserts for this interface at Install time and fails closed when the
|
||||
// host lacks it -- a declared customization is never silently dropped.
|
||||
//
|
||||
// Skill content has a single owner: a second customizing plugin, a FailOpen
|
||||
// declaration, or a SkillsOverlay that cannot compose aborts startup
|
||||
// unconditionally. EmbeddedSkills is a distribution build-integrity boundary:
|
||||
// silently dropping it could republish host defaults the distribution removed.
|
||||
// Removing a skill drops it from skills list/read and from structured
|
||||
// framework-owned pointers, but does not disable any command; use Restrict to
|
||||
// actually block a command.
|
||||
type EmbeddedSkillsRegistrar interface {
|
||||
// EmbeddedSkills contributes a SkillsOverlay customizing the CLI's
|
||||
// embedded skill content (see SkillsOverlay).
|
||||
EmbeddedSkills(spec *SkillsOverlay)
|
||||
}
|
||||
|
||||
63
extension/platform/registrar_compat_test.go
Normal file
63
extension/platform/registrar_compat_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// legacyRegistrarFake is a downstream Registrar implementation written
|
||||
// BEFORE EmbeddedSkills existed. It must keep compiling: doc.go declares
|
||||
// every exported symbol a stability contract, so Registrar can never widen.
|
||||
// If this file goes red, a method was added to Registrar -- move it to an
|
||||
// optional extension interface instead (see EmbeddedSkillsRegistrar).
|
||||
type legacyRegistrarFake struct{}
|
||||
|
||||
func (legacyRegistrarFake) Observe(When, string, Selector, Observer) {}
|
||||
func (legacyRegistrarFake) Wrap(string, Selector, Wrapper) {}
|
||||
func (legacyRegistrarFake) On(LifecycleEvent, string, LifecycleHandler) {}
|
||||
func (legacyRegistrarFake) Restrict(*Rule) {}
|
||||
|
||||
var _ Registrar = legacyRegistrarFake{}
|
||||
|
||||
// skillsRegistrarFake opts into the optional extension.
|
||||
type skillsRegistrarFake struct {
|
||||
legacyRegistrarFake
|
||||
got *SkillsOverlay
|
||||
}
|
||||
|
||||
func (f *skillsRegistrarFake) EmbeddedSkills(spec *SkillsOverlay) { f.got = spec }
|
||||
|
||||
var _ EmbeddedSkillsRegistrar = (*skillsRegistrarFake)(nil)
|
||||
|
||||
// Drive the legacy surface through the interface so the fake stays a
|
||||
// faithful stand-in for downstream usage (and none of it reads as dead code).
|
||||
func TestLegacyRegistrarFake_ImplementsContractSurface(t *testing.T) {
|
||||
var r Registrar = legacyRegistrarFake{}
|
||||
r.Observe(Before, "x.obs", All(), func(context.Context, Invocation) {})
|
||||
r.Wrap("x.wrap", All(), func(next Handler) Handler { return next })
|
||||
r.On(Startup, "x.boot", func(context.Context, *LifecycleContext) error { return nil })
|
||||
r.Restrict(&Rule{Deny: []string{"config/**"}})
|
||||
}
|
||||
|
||||
// A plugin that declared EmbeddedSkills fails closed against a host whose
|
||||
// registrar lacks the optional extension, and succeeds against one that has it.
|
||||
func TestBuiltPlugin_embeddedSkillsRequiresOptionalInterface(t *testing.T) {
|
||||
p := NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&SkillsOverlay{Remove: []string{"lark-a"}}).
|
||||
MustBuild()
|
||||
|
||||
if err := p.Install(legacyRegistrarFake{}); err == nil {
|
||||
t.Error("Install must fail closed when the host cannot honour EmbeddedSkills")
|
||||
}
|
||||
|
||||
host := &skillsRegistrarFake{}
|
||||
if err := p.Install(host); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if host.got == nil || len(host.got.Remove) != 1 || host.got.Remove[0] != "lark-a" {
|
||||
t.Errorf("SkillsOverlay must reach the opted-in host, got %+v", host.got)
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ package platform
|
||||
// Rule is the declarative policy rule data structure. yaml files and
|
||||
// Plugin.Restrict() both produce the same Rule.
|
||||
//
|
||||
// At any moment there is at most one effective Rule -- the resolver decides
|
||||
// which source wins (Plugin > yaml > none). This package only defines the
|
||||
// At any moment there is at most one effective SOURCE of rules -- the
|
||||
// resolver decides which wins (Plugin > yaml > none); the winning source
|
||||
// may contribute several scoped rules. This package only defines the
|
||||
// shape; selection lives in internal/cmdpolicy.
|
||||
//
|
||||
// The four filter fields are joined by AND. See the engine's Evaluate for
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user