mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
39 Commits
feat/apps-
...
feat/vc-ag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3390137092 | ||
|
|
d34c2d1284 | ||
|
|
b71c4261af | ||
|
|
165ba799e9 | ||
|
|
273a7f0733 | ||
|
|
e81c10fbc4 | ||
|
|
5c959738e5 | ||
|
|
885a466206 | ||
|
|
48ff5563c6 | ||
|
|
1baa0df60c | ||
|
|
abb056fc22 | ||
|
|
8f6c9bd44c | ||
|
|
5c4b2dbcdb | ||
|
|
aa14e4611d | ||
|
|
f02debbfbd | ||
|
|
9a2619e602 | ||
|
|
f39859e796 | ||
|
|
7d5e0977ac | ||
|
|
6e9dc16c95 | ||
|
|
13841801f5 | ||
|
|
39072db90c | ||
|
|
a13200db9a | ||
|
|
6c94898546 | ||
|
|
e726d5d964 | ||
|
|
25a1ad9cc8 | ||
|
|
265388f9e1 | ||
|
|
b1ea496842 | ||
|
|
314b4fcb2c | ||
|
|
e604e8ea3b | ||
|
|
d509ebca81 | ||
|
|
5dfab069e5 | ||
|
|
b4477c2aaf | ||
|
|
5e00d5d60d | ||
|
|
9aeebeffb8 | ||
|
|
200c4df713 | ||
|
|
2110531708 | ||
|
|
d6b4d03771 | ||
|
|
6ac793003a | ||
|
|
3e21db1e4e |
30
.github/CODEOWNERS
vendored
30
.github/CODEOWNERS
vendored
@@ -1,30 +0,0 @@
|
||||
/internal/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
/skills/lark-approval/
|
||||
/skills/lark-apps/
|
||||
/skills/lark-attendance/
|
||||
/skills/lark-base/
|
||||
/skills/lark-calendar/
|
||||
/skills/lark-contact/
|
||||
/skills/lark-doc/
|
||||
/skills/lark-drive/
|
||||
/skills/lark-event/
|
||||
/skills/lark-im/
|
||||
/skills/lark-mail/
|
||||
/skills/lark-markdown/
|
||||
/skills/lark-minutes/
|
||||
/skills/lark-okr/
|
||||
/skills/lark-openapi-explorer/
|
||||
/skills/lark-shared/
|
||||
/skills/lark-sheets/
|
||||
/skills/lark-skill-maker/
|
||||
/skills/lark-slides/
|
||||
/skills/lark-task/
|
||||
/skills/lark-vc/
|
||||
/skills/lark-vc-agent/
|
||||
/skills/lark-whiteboard/
|
||||
/skills/lark-wiki/
|
||||
/skills/lark-workflow-meeting-summary/
|
||||
/skills/lark-workflow-standup-report/
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -9,7 +9,7 @@
|
||||
## Test Plan
|
||||
<!-- Describe how this change was verified. -->
|
||||
- [ ] Unit tests pass
|
||||
- [ ] Manual local verification confirms the `lark-cli <domain> <command>` flow works as expected
|
||||
- [ ] Manual local verification confirms the `lark xxx` command works as expected
|
||||
|
||||
## Related Issues
|
||||
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->
|
||||
|
||||
101
.github/workflows/ci.yml
vendored
101
.github/workflows/ci.yml
vendored
@@ -5,12 +5,13 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
|
||||
@@ -62,7 +63,7 @@ jobs:
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
|
||||
lint:
|
||||
needs: fast-gate
|
||||
@@ -71,7 +72,6 @@ jobs:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -80,84 +80,8 @@ jobs:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Resolve changed-from baseline
|
||||
env:
|
||||
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
|
||||
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
|
||||
- name: Run errs/ lint guards (lintcheck)
|
||||
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
|
||||
|
||||
script-test:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Run script tests
|
||||
run: make script-test
|
||||
|
||||
deterministic-gate:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Resolve changed-from baseline
|
||||
env:
|
||||
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
|
||||
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
||||
- name: Write public content metadata
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PR_BRANCH: ${{ github.head_ref }}
|
||||
run: |
|
||||
mkdir -p .tmp/quality-gate
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"title": os.environ.get("PR_TITLE", ""),
|
||||
"body": os.environ.get("PR_BODY", ""),
|
||||
"branch": os.environ.get("PR_BRANCH", ""),
|
||||
}, f)
|
||||
f.write("\n")
|
||||
PY
|
||||
- name: Run CLI deterministic gate
|
||||
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
|
||||
- name: Upload quality gate facts
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: quality-gate-facts-${{ github.event.pull_request.base.sha }}-${{ github.event.pull_request.head.sha }}
|
||||
path: .tmp/quality-gate/facts.json
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
|
||||
coverage:
|
||||
needs: fast-gate
|
||||
@@ -177,7 +101,6 @@ jobs:
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
|
||||
with:
|
||||
files: coverage.txt
|
||||
@@ -259,7 +182,7 @@ jobs:
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
needs: [unit-test, lint]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
@@ -280,12 +203,9 @@ jobs:
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
needs: [unit-test, lint]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
@@ -332,9 +252,6 @@ jobs:
|
||||
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
@@ -372,7 +289,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -384,8 +301,6 @@ jobs:
|
||||
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -401,8 +316,6 @@ jobs:
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
"${{ needs.unit-test.result }}" \
|
||||
"${{ needs.lint.result }}" \
|
||||
"${{ needs.script-test.result }}" \
|
||||
"${{ needs.deterministic-gate.result }}" \
|
||||
"${{ needs.coverage.result }}" \
|
||||
"${{ needs.deadcode.result }}" \
|
||||
"${{ needs.e2e-dry-run.result }}" \
|
||||
|
||||
28
.github/workflows/comment-audit.yml
vendored
28
.github/workflows/comment-audit.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Comment Audit
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
pull_request_review:
|
||||
types: [submitted, edited]
|
||||
pull_request_review_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
public-content-comment-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Post-publication comment audit
|
||||
run: |
|
||||
mkdir -p .tmp/comment-audit
|
||||
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
|
||||
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"
|
||||
611
.github/workflows/semantic-review.yml
vendored
611
.github/workflows/semantic-review.yml
vendored
@@ -1,611 +0,0 @@
|
||||
name: Semantic Review
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pr-quality-summary:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Verify workflow run and pull request for summary
|
||||
id: pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
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}`);
|
||||
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");
|
||||
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
|
||||
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
|
||||
if (runPRs.length > 1) {
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
|
||||
let factsArtifactName = "";
|
||||
let artifactBaseSha = "";
|
||||
let artifactError = "";
|
||||
if (factsArtifacts.length !== 1) {
|
||||
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
|
||||
} else {
|
||||
factsArtifactName = factsArtifacts[0].name;
|
||||
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (artifactError) {
|
||||
core.warning(`quality gate facts artifact binding is unavailable: ${artifactError}`);
|
||||
}
|
||||
core.setOutput("pr_number", String(prNumber));
|
||||
core.setOutput("head_sha", targetHeadSha);
|
||||
core.setOutput("base_sha", baseSha);
|
||||
core.setOutput("run_id", String(run.id));
|
||||
core.setOutput("facts_artifact_name", factsArtifactName);
|
||||
core.setOutput("artifact_error", artifactError);
|
||||
core.setOutput("stale", "false");
|
||||
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
id: checkout
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.base_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify summary facts artifact metadata
|
||||
id: artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' && steps.pr.outputs.facts_artifact_name != '' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
|
||||
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
|
||||
const artifact = artifacts[0];
|
||||
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
|
||||
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
|
||||
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
|
||||
}
|
||||
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
|
||||
core.setOutput("artifact_id", String(artifact.id));
|
||||
core.setOutput("artifact_digest", artifact.digest);
|
||||
|
||||
- name: Download facts artifact zip
|
||||
if: ${{ steps.pr.outputs.stale != 'true' && steps.artifact.outputs.artifact_id != '' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
id: download
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
|
||||
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
|
||||
const { data } = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifactId,
|
||||
archive_format: "zip",
|
||||
});
|
||||
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
|
||||
fs.writeFileSync(zipPath, Buffer.from(data));
|
||||
core.setOutput("zip_path", zipPath);
|
||||
|
||||
- name: Verify and extract summary facts artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' && steps.download.outputs.zip_path != '' }}
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_DECISION_OUT: decision.json
|
||||
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
|
||||
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
|
||||
|
||||
- name: Publish PR quality summary
|
||||
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
CI_QUALITY_SUMMARY_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
CI_QUALITY_SUMMARY_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
CI_QUALITY_SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
CI_QUALITY_SUMMARY_RUN_ID: ${{ steps.pr.outputs.run_id }}
|
||||
CI_QUALITY_SUMMARY_ARTIFACT_ERROR: ${{ steps.pr.outputs.artifact_error }}
|
||||
with:
|
||||
script: |
|
||||
const { publish } = require("./scripts/ci-quality-summary-publish.js");
|
||||
await publish({ github, context, core });
|
||||
|
||||
semantic-review:
|
||||
needs: pr-quality-summary
|
||||
if: always() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
checks: write
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Verify workflow run and pull request
|
||||
id: pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
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}`);
|
||||
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");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
|
||||
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
|
||||
if (runPRs.length > 1) {
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
|
||||
let factsArtifactName = "";
|
||||
let artifactBaseSha = "";
|
||||
let artifactError = "";
|
||||
if (factsArtifacts.length !== 1) {
|
||||
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
|
||||
} else {
|
||||
factsArtifactName = factsArtifacts[0].name;
|
||||
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (!pr.head.repo) {
|
||||
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR base");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (artifactError) {
|
||||
core.warning(`semantic review facts artifact binding is unavailable: ${artifactError}`);
|
||||
}
|
||||
core.setOutput("pr_number", String(prNumber));
|
||||
core.setOutput("head_sha", targetHeadSha);
|
||||
core.setOutput("base_sha", baseSha);
|
||||
core.setOutput("head_owner", pr.head.repo.owner.login);
|
||||
core.setOutput("head_repo", pr.head.repo.name);
|
||||
core.setOutput("head_repo_id", String(pr.head.repo.id));
|
||||
core.setOutput("head_is_base_repo", pr.head.repo.id === context.payload.repository.id ? "true" : "false");
|
||||
core.setOutput("run_id", String(run.id));
|
||||
core.setOutput("facts_artifact_name", factsArtifactName);
|
||||
core.setOutput("artifact_error", artifactError);
|
||||
core.setOutput("stale", "false");
|
||||
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
id: checkout
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.base_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Publish pre-checkout semantic review failure
|
||||
if: ${{ failure() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome != 'success' && steps.pr.outputs.head_sha != '' && steps.pr.outputs.pr_number != '' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
|
||||
with:
|
||||
script: |
|
||||
const runtimeBlockMode = process.env.SEMANTIC_REVIEW_BLOCK === "true";
|
||||
const pr = Number(process.env.SEMANTIC_REVIEW_PR_NUMBER || 0);
|
||||
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
|
||||
const baseSha = process.env.SEMANTIC_REVIEW_BASE_SHA || "";
|
||||
if (!Number.isInteger(pr) || pr <= 0 || !/^[a-f0-9]{40}$/i.test(headSha) || !/^[a-f0-9]{40}$/i.test(baseSha)) {
|
||||
throw new Error("missing verified semantic review target");
|
||||
}
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr,
|
||||
});
|
||||
if (pull.state !== "open") {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
|
||||
return;
|
||||
}
|
||||
if (pull.head.sha !== headSha) {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR head changed");
|
||||
return;
|
||||
}
|
||||
if (pull.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR base changed");
|
||||
return;
|
||||
}
|
||||
if (pull.base.repo.id !== context.payload.repository.id) {
|
||||
throw new Error("PR base repo mismatch before infrastructure failure check");
|
||||
}
|
||||
await github.rest.checks.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: runtimeBlockMode ? "semantic-review/result" : "semantic-review/observe",
|
||||
head_sha: headSha,
|
||||
status: "completed",
|
||||
conclusion: runtimeBlockMode ? "failure" : "neutral",
|
||||
output: {
|
||||
title: "Semantic review infrastructure failure",
|
||||
summary: "Semantic review could not checkout the verified base commit. Inspect the workflow logs before relying on semantic review output.",
|
||||
},
|
||||
});
|
||||
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Verify semantic facts artifact metadata
|
||||
id: artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
|
||||
if (!/^quality-gate-facts-[a-f0-9]{40}-[a-f0-9]{40}$/i.test(factsArtifactName)) {
|
||||
throw new Error("missing verified facts artifact binding");
|
||||
}
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
|
||||
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
|
||||
const artifact = artifacts[0];
|
||||
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
|
||||
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
|
||||
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
|
||||
}
|
||||
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
|
||||
core.setOutput("artifact_id", String(artifact.id));
|
||||
core.setOutput("artifact_digest", artifact.digest);
|
||||
|
||||
- name: Download facts artifact zip
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
id: download
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
|
||||
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
|
||||
const { data } = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifactId,
|
||||
archive_format: "zip",
|
||||
});
|
||||
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
|
||||
fs.writeFileSync(zipPath, Buffer.from(data));
|
||||
core.setOutput("zip_path", zipPath);
|
||||
|
||||
- name: Verify and extract semantic facts artifact
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_DECISION_OUT: decision.json
|
||||
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
|
||||
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
|
||||
|
||||
- name: Download PR semantic waiver config
|
||||
id: waiver_config
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
SEMANTIC_REVIEW_HEAD_OWNER: ${{ steps.pr.outputs.head_owner }}
|
||||
SEMANTIC_REVIEW_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
|
||||
SEMANTIC_REVIEW_HEAD_IS_BASE_REPO: ${{ steps.pr.outputs.head_is_base_repo }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
|
||||
if (!/^[a-f0-9]{40}$/i.test(headSha)) {
|
||||
throw new Error("missing verified semantic review target");
|
||||
}
|
||||
const headOwner = process.env.SEMANTIC_REVIEW_HEAD_OWNER || "";
|
||||
const headRepo = process.env.SEMANTIC_REVIEW_HEAD_REPO || "";
|
||||
if (!headOwner || !headRepo) {
|
||||
throw new Error("missing verified semantic review head repository");
|
||||
}
|
||||
const waiverPath = "internal/qualitygate/config/semantic/waivers.txt";
|
||||
const outPath = path.join(process.env.RUNNER_TEMP, "semantic-review-waivers.txt");
|
||||
const headIsBaseRepo = process.env.SEMANTIC_REVIEW_HEAD_IS_BASE_REPO === "true";
|
||||
if (!headIsBaseRepo) {
|
||||
core.notice("fork PR semantic waiver config is ignored");
|
||||
core.setOutput("path", "");
|
||||
return;
|
||||
}
|
||||
let content = "";
|
||||
try {
|
||||
const { data } = await github.rest.repos.getContent({
|
||||
owner: headOwner,
|
||||
repo: headRepo,
|
||||
path: waiverPath,
|
||||
ref: headSha,
|
||||
});
|
||||
if (Array.isArray(data) || data.type !== "file" || data.encoding !== "base64") {
|
||||
throw new Error(`${waiverPath} is not a base64 file at PR head`);
|
||||
}
|
||||
if (data.size > 256 * 1024) {
|
||||
throw new Error(`${waiverPath} is too large: ${data.size} bytes`);
|
||||
}
|
||||
content = Buffer.from(data.content, "base64").toString("utf8");
|
||||
} catch (err) {
|
||||
if (err.status !== 404) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(outPath, content);
|
||||
core.setOutput("path", outPath);
|
||||
|
||||
- name: Run semantic review
|
||||
id: semantic
|
||||
if: ${{ steps.pr.outputs.stale != 'true' }}
|
||||
env:
|
||||
ARK_API_KEY: ${{ secrets.ARK_API_KEY }}
|
||||
ARK_BASE_URL: ${{ vars.ARK_BASE_URL }}
|
||||
ARK_MODEL: ${{ vars.ARK_MODEL }}
|
||||
ARK_TIMEOUT_SECONDS: ${{ vars.ARK_TIMEOUT_SECONDS }}
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
run: |
|
||||
args=(
|
||||
--repo .
|
||||
--facts facts.json
|
||||
--decision-out decision.json
|
||||
--markdown-out semantic-review.md
|
||||
)
|
||||
if [ -n "${{ steps.waiver_config.outputs.path }}" ]; then
|
||||
args+=(--waivers-file '${{ steps.waiver_config.outputs.path }}')
|
||||
fi
|
||||
if [ "$SEMANTIC_REVIEW_BLOCK" = "true" ]; then
|
||||
args+=(--block)
|
||||
fi
|
||||
go run ./internal/qualitygate/cmd/semantic-review "${args[@]}"
|
||||
|
||||
- name: Publish semantic review
|
||||
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
|
||||
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
|
||||
with:
|
||||
script: |
|
||||
const { publish } = require("./scripts/semantic-review-publish.js");
|
||||
await publish({ github, context, core });
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
# Build output
|
||||
/lark-cli*
|
||||
/lark-cli
|
||||
.cache/
|
||||
dist/
|
||||
bin/
|
||||
@@ -7,11 +7,6 @@ bin/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python (skill-bundled helper scripts)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -39,16 +34,8 @@ tests/mail/reports/
|
||||
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
/notes/
|
||||
/minutes/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
.tmp/
|
||||
cover*.out
|
||||
|
||||
lark-env.sh
|
||||
/automations/
|
||||
|
||||
@@ -14,4 +14,3 @@ id = "lark-session-token"
|
||||
description = "Detect Lark session tokens"
|
||||
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
|
||||
keywords = ["XN0YXJ0-", "-WVuZA"]
|
||||
|
||||
|
||||
@@ -29,11 +29,11 @@ linters:
|
||||
- unused # checks for unused constants, variables, functions and types
|
||||
- depguard # blocks forbidden package imports
|
||||
- forbidigo # forbids specific function calls
|
||||
- errorlint # enforces error wrapping (%w) and errors.Is/As over == and type asserts
|
||||
|
||||
# To enable later after fixing existing issues:
|
||||
# - errcheck # checks for unchecked errors
|
||||
# - errname # checks that error types are named XxxError
|
||||
# - errorlint # checks error wrapping best practices
|
||||
# - gosec # security-oriented linter
|
||||
# - misspell # finds commonly misspelled English words
|
||||
# - staticcheck # comprehensive static analysis
|
||||
@@ -45,53 +45,21 @@ linters:
|
||||
- path: _test\.go$
|
||||
linters:
|
||||
- bodyclose
|
||||
- bidichk
|
||||
- gocritic
|
||||
- depguard
|
||||
- forbidigo
|
||||
- errorlint # tests legitimately do identity (==) and concrete type-assert checks
|
||||
# forbidigo runs repo-wide (minus the boundaries below) so errs-no-bare-wrap
|
||||
# has no gap. The framework bans (os/vfs, raw HTTP, fmt.Print, filepath,
|
||||
# log) stay scoped to shortcuts/ + internal/ + config/auth/service via the
|
||||
# next rule; elsewhere only errs-no-bare-wrap fires.
|
||||
- path-except: (shortcuts/|internal/|cmd/|events/)
|
||||
linters:
|
||||
- forbidigo
|
||||
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/)
|
||||
text: (vfs|IOStreams|ctx\.Out|shortcuts-no-raw-http|filepath functions|os\.Exit|structured error return)
|
||||
- path-except: (shortcuts/|internal/)
|
||||
linters:
|
||||
- forbidigo
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
# internal/gen build-time generators (standalone `package main` run via
|
||||
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
|
||||
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
|
||||
# impossible here: a structured error return needs os.Exit (also banned),
|
||||
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
|
||||
- path: shortcuts/.*/internal/gen/
|
||||
linters:
|
||||
- forbidigo
|
||||
# internal/qualitygate/cmd contains standalone CI tools. Their main
|
||||
# entrypoints legitimately own process exit codes and stdio, matching the
|
||||
# old tools/ layout before these packages moved under internal/.
|
||||
- path: internal/qualitygate/cmd/[^/]+/main\.go$
|
||||
linters:
|
||||
- forbidigo
|
||||
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
||||
# for the client / credential layer.
|
||||
# The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
|
||||
# internal/ legitimately wraps raw HTTP for the client / credential layer.
|
||||
- path-except: shortcuts/
|
||||
text: shortcuts-no-raw-http
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced across every command/wire boundary by
|
||||
# structural prefix, so any future business domain or command is covered
|
||||
# without editing an allowlist. Genuine intermediate wraps inside these
|
||||
# paths use //nolint:forbidigo with a reason.
|
||||
- path-except: (cmd/|shortcuts/|events/)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
@@ -110,12 +78,6 @@ linters:
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── bare error wraps banned on fully-typed paths ──
|
||||
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||
msg: >-
|
||||
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
|
||||
wrap a cause with .WithCause(err). Genuine intermediate wraps:
|
||||
//nolint:forbidigo with a reason.
|
||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||
# Bans request / client construction; constants (http.MethodPost,
|
||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||
|
||||
@@ -17,7 +17,6 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
|
||||
archives:
|
||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
|
||||
28
AGENTS.md
28
AGENTS.md
@@ -11,7 +11,7 @@
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make unit-test # Required before PR (runs with -race)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
@@ -75,31 +75,7 @@ The one rule to internalize: **every error message you write will be parsed by a
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
|
||||
|
||||
Picking a constructor:
|
||||
|
||||
| Failure | Constructor |
|
||||
|---------|-------------|
|
||||
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
|
||||
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
|
||||
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
|
||||
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
|
||||
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
|
||||
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
|
||||
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
|
||||
|
||||
Signatures that are easy to guess wrong:
|
||||
|
||||
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
|
||||
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }` — `ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
|
||||
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
|
||||
|
||||
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
|
||||
|
||||
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
|
||||
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
|
||||
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
|
||||
702
CHANGELOG.md
702
CHANGELOG.md
@@ -2,674 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.58] - 2026-06-25
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
|
||||
- **base**: Add Base URL and title resolve shortcuts (#1338)
|
||||
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
|
||||
- **doc**: Support `create` title option (#1536)
|
||||
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
|
||||
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
|
||||
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
|
||||
- **task**: Add task event consumer (#1510)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Prefix docs resource shortcuts (#1564)
|
||||
- **binding**: Skip unix mode audit on Windows (#1525)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Sync approval skill for meta API commands (#1499)
|
||||
- **doc**: Restore lark-doc style requirements (#1579)
|
||||
- **im**: Document `chat.nickname` get/update/delete (#1378)
|
||||
- **im**: Clarify audio message opus requirement (#1271)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Add public content safeguards and reduce false positives
|
||||
|
||||
## [v1.0.57] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
|
||||
- **base**: Support record comments (#1043)
|
||||
- **search**: Surface search API notices (#1413)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
|
||||
- **meta**: Backfill enum value descriptions from options (#1541)
|
||||
- **cli**: Add missing CLI headers for git credential helper (#1539)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Refine rich block, path, and block ID guidance (#1508)
|
||||
- **mail**: Trim lark-mail skill context (#1527)
|
||||
- **drive**: Add permission governance workflow guidance (#1292)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Bind semantic review to workflow run head (#1551)
|
||||
|
||||
## [v1.0.56] - 2026-06-18
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api**: Align API success envelopes (#1489)
|
||||
- **base**: Reject out-of-range pagination flags (#1495)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Retire legacy error envelopes and enforce typed contract (#1449)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Soften lark-doc style guidance (#1463)
|
||||
|
||||
### Build
|
||||
|
||||
- Add CI quality gate with semantic review
|
||||
|
||||
## [v1.0.55] - 2026-06-16
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Support agent meeting event workflows (#1483)
|
||||
- **drive**: Support exporting Base structure snapshots (#1481)
|
||||
- **doc**: Add docx cover resource commands (#1468)
|
||||
- **doc**: Support `lang` for docx fetch v2 (#1459)
|
||||
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Validate drive import folder target (#1485)
|
||||
|
||||
## [v1.0.54] - 2026-06-15
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
|
||||
- **drive**: Support `original_creator_ids` filter in search (#1046)
|
||||
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Fix docs fetch and update ergonomics (#1466)
|
||||
- **vfs**: Reject blank local paths (#1460)
|
||||
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
|
||||
- **event**: Clarify remote bus blocker recovery (#1454)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Converge command pipelines onto a typed metadata model + catalog (#1191)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
|
||||
- **doc**: Clarify lark-doc create title guidance (#1474)
|
||||
- **skills**: Add rename prompt for import without `--name` (#1461)
|
||||
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
|
||||
|
||||
## [v1.0.53] - 2026-06-12
|
||||
|
||||
### Features
|
||||
|
||||
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
|
||||
- **auth**: Add `--json` flag support to auth subcommands (#1431)
|
||||
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
|
||||
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
|
||||
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Optimize whiteboard skill (#1371)
|
||||
- **skills**: Optimize okr skill (#1368)
|
||||
|
||||
## [v1.0.52] - 2026-06-11
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Per-resource subscription identity + Match hook (#1185)
|
||||
- **apps**: Emit typed error envelopes across the apps domain (#1288)
|
||||
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
|
||||
- **im**: Add `--chat-modes` filter to chat search (#1317)
|
||||
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
|
||||
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Support git credential dry-run (#1390)
|
||||
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
|
||||
- **build**: Make `-race` flag arch-conditional to support riscv64
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
|
||||
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
|
||||
- **skills**: Optimize lark-drive skill routing (#1284)
|
||||
- **skills**: Expand cite user guidance and fix typos (#1394)
|
||||
|
||||
## [v1.0.51] - 2026-06-10
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Support multi dev modes (#1175)
|
||||
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
|
||||
- **base**: Configure initial base table schema (#1377)
|
||||
- **vc**: Add recording event support (#1369)
|
||||
- **minutes**: Replace words for transcript (#1372)
|
||||
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
|
||||
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
|
||||
- **slides**: Emit typed error envelopes across the slides domain (#1349)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
|
||||
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
|
||||
|
||||
## [v1.0.50] - 2026-06-09
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Emit typed error envelopes across the doc domain (#1346)
|
||||
- **event**: Emit typed error envelopes across the event domain (#1289)
|
||||
- **contact**: Emit typed error envelopes across the contact domain (#1287)
|
||||
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
|
||||
- **cli**: Adjust agent timeout hint output conditions (#1328)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
|
||||
- **slides**: Build create URL locally instead of drive metas call (#1329)
|
||||
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
|
||||
- **doc**: Document `<folder-manager>` resource block (#1168)
|
||||
- **drive**: Add drive comment location guidance (#1258)
|
||||
|
||||
## [v1.0.49] - 2026-06-08
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Add whiteboard event domain with per-board subscription (#1265)
|
||||
- **im**: Support feed group (#1102)
|
||||
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
|
||||
- **im**: Format feed group error handling (#1308)
|
||||
- **im**: Return typed error envelopes across the im domain (#1230)
|
||||
- **base**: Emit typed error envelopes across the base domain (#1248)
|
||||
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
|
||||
- **task**: Emit typed error envelopes across the task domain (#1231)
|
||||
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
|
||||
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
|
||||
- **markdown**: Harden create upload failures (#1325)
|
||||
- **drive**: Harden inspect shortcut failures (#1324)
|
||||
- **slides**: Add IconPark lookup for Lark slides (#1123)
|
||||
- **doc**: Remove docs v1 API (#1291)
|
||||
- **cli**: Add `skills` command to read embedded skill content (#1318)
|
||||
- **cli**: Fetch official skills index (#1301)
|
||||
- **shared**: Document relative-path-only file arguments (#1319)
|
||||
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
|
||||
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
|
||||
- **drive**: Use docs secure label read scope (#1281)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
|
||||
- **skills**: Tighten drive and markdown guardrails (#1326)
|
||||
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
|
||||
- **markdown**: Add markdown domain template (#1293)
|
||||
- **markdown**: Improve lark-markdown skill guidance (#1279)
|
||||
- **doc**: Improve lark-doc skill guidance (#1283)
|
||||
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
|
||||
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
|
||||
|
||||
## [v1.0.48] - 2026-06-04
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
|
||||
- **contact**: Add contact skill domain guidance (#1144)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **skills**: Use JSON skills list during update (#1251)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
|
||||
- **vc-agent**: Require explicit leave request (#1260)
|
||||
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
|
||||
|
||||
## [v1.0.47] - 2026-06-03
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
|
||||
- **base**: Add base block shortcuts (#1044)
|
||||
- **im**: Complete card message format (#1198)
|
||||
- **im**: Improve markdown guidance for messages (#1237)
|
||||
- **vc**: Forward invite call-id on meeting join (#1243)
|
||||
- **drive**: Emit typed error envelopes across the drive domain (#1205)
|
||||
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
|
||||
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
|
||||
- **wiki**: Support `appid` member type (#1235)
|
||||
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
|
||||
- **config**: Validate credentials after `config init` (#1151)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **skills**: Recover empty fallback for skills to update (#1233)
|
||||
|
||||
## [v1.0.46] - 2026-06-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add card message format support (#1218)
|
||||
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
|
||||
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
|
||||
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
|
||||
- **agent**: Increase agent trace max length to 1024 (#1211)
|
||||
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Remove FLAGS section from root `--help` (#1226)
|
||||
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Optimize base skill references (#1171)
|
||||
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
|
||||
|
||||
## [v1.0.45] - 2026-06-01
|
||||
|
||||
### Features
|
||||
|
||||
- **errors**: Add typed envelope contract for auth-domain errors (#1135)
|
||||
- **platform**: Support multiple policy rules per plugin (#1182)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **vc**: Add domain boundaries and enrich `+notes` (#1172)
|
||||
- **whiteboard**: Fix whiteboard skill (#1180)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **auth**: Update login hint and split-flow docs (#1201)
|
||||
|
||||
## [v1.0.44] - 2026-05-29
|
||||
|
||||
### Features
|
||||
|
||||
- **base**: Add dashboard block data shortcut and workflow docs (#1067)
|
||||
- **im**: Support `--types` flag for listing p2p single chats in `chat-list` (#1077)
|
||||
- **agent**: Add agent header support (#1158)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **im**: Correct 64-bit MP4 box size handling to prevent panic on crafted media (#1165)
|
||||
- **install**: Detect curl version before using `--ssl-revoke-best-effort` (#1124)
|
||||
- **vc**: Correct `--minute-token` to `--minute-tokens` in recording reference (#1170)
|
||||
- **whiteboard**: Fix whiteboard skill (#1166)
|
||||
|
||||
## [v1.0.43] - 2026-05-28
|
||||
|
||||
### Features
|
||||
|
||||
- **event**: Support `note` generated event (#1159)
|
||||
- **config**: Decouple `--lang` preference from TUI display language (#1132)
|
||||
- **mail**: Add HTML lint library with Larksuite-native autofix for `lark-mail` (#1019)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **config**: Propagate `Lang` across credential boundary; respect `CurrentApp` in priorLang (#1157)
|
||||
- **config**: Allow lark-channel bind source override (#1154)
|
||||
- **im**: Clarify `messages-send` dry-run chat membership (#1150)
|
||||
- **base**: Include `log_id` in attachment media errors (#1133)
|
||||
|
||||
### Performance
|
||||
|
||||
- **im**: Parallelize reactions, thread_replies, and merge_forward fetches (#1146)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Update IM skill urgent APIs (#1153)
|
||||
|
||||
## [v1.0.42] - 2026-05-27
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Add `+draft-send` shortcut for batch draft sending (#1017)
|
||||
- **im**: Enrich messages with reactions and output `update_time` (#1095)
|
||||
- **schema**: Output JSON spec envelope for all API commands (#1048)
|
||||
- **event**: Support `vc` / `note` / `minute` events (#1113)
|
||||
- **drive**: Add secure label shortcuts (#985)
|
||||
- **affordance**: Use description and command in affordance example schema (#1126)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **docs**: Remove unsupported `fetch` text format (#1109)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **auth**: Drop duplicate top-level user fields in `status` (#1128)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Document block anchor URLs in `lark-doc` skill (#1120)
|
||||
- **whiteboard**: Improve SVG/Mermaid instructions (#1097)
|
||||
|
||||
## [v1.0.41] - 2026-05-26
|
||||
|
||||
### Features
|
||||
|
||||
- **minutes**: Add minutes edit shortcuts (#1036)
|
||||
- **minutes**: Get minutes keywords (#1079)
|
||||
- **slides**: Support importing pptx as slides (#1068)
|
||||
- **config**: Add `keychain-downgrade` subcommand (macOS) (#1085)
|
||||
- **errors**: Add structured CLI error contract (#984)
|
||||
- **apps**: Replace `+html-publish` cwd hard-reject with credential-file scan (#1072)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Support doubao drive inspect URL variants (#1106)
|
||||
- **skills**: Sync skills incrementally during update (#1042)
|
||||
- **apps**: Read app object from `data.app` for `+create` and `+update` (#1087)
|
||||
- **common**: Escape special chars in multipart form filenames (#1037)
|
||||
- **auth**: Remove fenced code block guidance from auth URL output hints (#1088)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Fix agent routing for doubao.com URLs (#1082)
|
||||
- **task**: Require `--complete=false` for pending standup summaries (#1101)
|
||||
- **base**: Document UI-only field settings (#1078)
|
||||
- **contributing**: Clarify contributor guidance (#1096)
|
||||
|
||||
## [v1.0.40] - 2026-05-25
|
||||
|
||||
### Features
|
||||
|
||||
- **wiki**: Add exponential backoff retry for `+node-create` lock contention (#1012)
|
||||
- **auth**: Add `auth qrcode` subcommand and update auth docs/hints (#968)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **wiki**: Rename `+node-get --token` to `--node-token`, keep alias (#1074)
|
||||
- **output**: Classify wiki lock-contention error (131009) with retry hint (#1014)
|
||||
- **contact**: Add actionable hint when fanout search all-fail with no API code (#1054)
|
||||
- **permission**: Annotate auto-grant permission failures with `required_scope` and `console_url` (#1045)
|
||||
- **validation**: Use `ErrValidation` instead of `fmt.Errorf` in `Validate` paths (#1001)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Add 云盘/云存储 alias alongside 云空间 for agent clarity (#1073)
|
||||
- **task**: Refresh `lark-task` shortcut docs (#1057)
|
||||
|
||||
## [v1.0.39] - 2026-05-22
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+export` shortcut to export slides (#988)
|
||||
- **sidecar**: Support multi-client identity isolation in `server-demo` via per-client HMAC keys, preventing UAT cross-contamination when multiple CLI sandboxes share one sidecar (#934)
|
||||
- **im**: Support Markdown image rendering in post content (#893)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scope**: Add 22 new scope entries to scope priorities (#1050)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Update location `full_address` guidance (#754)
|
||||
- **apps**: Refine `lark-apps` skill description and surface, document `index.html` / `--path` hard constraints (#1040)
|
||||
|
||||
## [v1.0.38] - 2026-05-22
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Gate the Miaoda apps domain off on the Lark brand — the `apps` shortcut subtree returns a structured brand-restriction error, `auth login --domain apps` is rejected, `--domain all` skips it, and `spark:*` scopes are no longer requested (#1025)
|
||||
|
||||
## [v1.0.37] - 2026-05-21
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add miaoda apps domain with 6 shortcuts covering `+create` / `+update` / `+list` / `+access-scope-get` / `+access-scope-set` / `+html-publish` (#1002)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **permission**: Surface auto-grant skipped/failed cases via stderr warnings and a `hint` field in the `permission_grant` JSON output (#1015)
|
||||
- **sheets**: Use `FileIO` for `+write-image` input so stdin / `-` works consistently (#996)
|
||||
|
||||
## [v1.0.36] - 2026-05-21
|
||||
|
||||
### Features
|
||||
|
||||
- **drive/markdown**: Return real tenant URLs for `drive +upload` and `markdown +create` (#992)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Return validation error when `--scope` is empty in `auth check` (#999)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-drive**: Improve search evidence guidance (#864)
|
||||
|
||||
## [v1.0.35] - 2026-05-20
|
||||
|
||||
### Features
|
||||
|
||||
- **markdown**: Support wiki node target in `+create` (#883)
|
||||
- **markdown**: Add `+diff` shortcut (#876)
|
||||
- **base**: Add form `+detail` / `+submit` shortcuts (#759)
|
||||
- **skills**: Add incremental skills sync (#965)
|
||||
- **doc**: Warn before overwrite when document contains whiteboard or file blocks (#825)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Clarify media key formats for message media flags (#991)
|
||||
- **im**: Add media-preview reference (#990)
|
||||
- **drive**: Migrate `docs +search` to `drive +search` and fix `creator_ids` owner semantic (#951)
|
||||
- **drive**: Prefer local comments for drive reviews (#981)
|
||||
- **wiki**: Add wiki base fast path (#982)
|
||||
|
||||
## [v1.0.34] - 2026-05-19
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Switch markdown export to V2 `docs_ai` fetch API (#948)
|
||||
- **drive**: Add `+inspect` shortcut for document URL inspection with wiki unwrapping (#947)
|
||||
- **wiki**: Add `+node-get` / `+node-delete` / `+space-create` shortcuts (#904)
|
||||
- **base**: Support Base attachment APIs (#887)
|
||||
- **mail**: Validate `bot` + `mailbox=me` and add dynamic `--as` help tests (#895)
|
||||
- **mail**: Expose draft priority in `--inspect` projection and document `--set-priority` (#779)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **identitydiag**: Harden verify path and tighten status semantics (#961)
|
||||
- **wiki**: Surface real node URL for `+node-create` / `+node-copy` (#960)
|
||||
- **auth**: Split bot and user identity diagnostics (#957)
|
||||
- **base**: Address Base attachment review follow-ups (#958)
|
||||
- **docs**: Clarify `replace_all` selection errors (#954)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Clarify add comment constraints (#967)
|
||||
- **lark-im**: Clarify message activity search (#865)
|
||||
|
||||
### Tests
|
||||
|
||||
- Verify e2e resource cleanup (#949)
|
||||
- **lint**: Exclude `bidichk` from test files (#959)
|
||||
|
||||
## [v1.0.33] - 2026-05-18
|
||||
|
||||
### Features
|
||||
|
||||
- **markdown**: Add `+patch` shortcut (#857)
|
||||
- **slides**: Improve slide planning and validation guidance (#847)
|
||||
- **drive**: Add `+sync` workflow for Drive directories (#873)
|
||||
- **drive**: Add drive version shortcut (#841)
|
||||
- **extension**: Plugin / Hook framework with command pruning (#910)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **sheets**: Explicitly document safe JSON unmarshal ignore in `DryRun` (#935)
|
||||
- **base**: Mark base field update high risk (#936)
|
||||
- **auth**: Guide agents to yield during auth device flow (#933)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-wiki**: Correct the `--as` default-identity claim (#919)
|
||||
|
||||
### Tests
|
||||
|
||||
- Drop stale e2e `--yes` flags (#920)
|
||||
|
||||
## [v1.0.32] - 2026-05-15
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Add `--width`/`--height` flags to `docs +media-insert` (#832)
|
||||
- **wiki**: Add `+space-list` / `+node-list` / `+node-copy` shortcuts (#392)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Preserve parent token on nested overwrite (#908)
|
||||
- **selfupdate**: Use `LookPath` instead of `Executable` for binary verification (#886)
|
||||
- **registry**: Wait for background meta refresh before test reset (#894)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Add SVG whiteboard support to `lark-doc` v2 skill (#901)
|
||||
- **drive**: Add permission public patch error guidance (#863)
|
||||
|
||||
## [v1.0.31] - 2026-05-14
|
||||
|
||||
### Features
|
||||
|
||||
- **install**: Skip interactive prompts in non-TTY environments (#888)
|
||||
- **update**: Recommend `lark-cli update` over `npm install` for AI agents (#884)
|
||||
- **im**: Add `--exclude-muted` to `+chat-search` and new `+chat-list` shortcut (#820)
|
||||
- **auth**: Add `--exclude` flag and allow combining `--scope` with `--domain`/`--recommend` (#844)
|
||||
- **drive**: Add modified-time smart sync mode (#859)
|
||||
- **approval**: Add `tasks.add_sign` and `tasks.rollback` methods (#867)
|
||||
|
||||
## [v1.0.30] - 2026-05-13
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add `--chat-mode topic` to `+chat-create` (#790)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Support comma-separated `--scope` in `auth login` (#764)
|
||||
- **auth**: Clarify URL handling in auth messages and docs (#856)
|
||||
- **bind**: Accept `~/` paths in OpenClaw secret references (#839)
|
||||
|
||||
### Tests
|
||||
|
||||
- **update**: Isolate stamp writes from real `~/.lark-cli/skills.stamp` (#858)
|
||||
|
||||
## [v1.0.29] - 2026-05-12
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Add agent meeting join, leave, and events shortcuts (#824)
|
||||
- **mail**: Add unknown-flag fuzzy match for `lark-cli mail` commands (#806)
|
||||
- **whiteboard**: Pin `whiteboard-cli` to `v0.2.11` in `lark-whiteboard` skill (#850)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Silence misleading "skills not installed" startup notice (#801)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Refine data analysis SOP wording (#784, #849)
|
||||
- Update README capability descriptions (#793)
|
||||
|
||||
## [v1.0.28] - 2026-05-11
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support UAT for `messages.forward` and add `threads.forward` (#689)
|
||||
- **im**: Add flag shortcuts `+flag-create` / `+flag-list` / `+flag-cancel` for message bookmarks (#770)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Handle duplicate remote sync paths (#803)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Name `--query` / `--member-ids` in `+chat-search` shortcut row (#812)
|
||||
|
||||
## [v1.0.27] - 2026-05-09
|
||||
|
||||
### Features
|
||||
|
||||
- **config**: Add `lark-channel` as a bind source (#786)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Fix installation errors when PowerShell is disabled by Group Policy (#789)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **task**: Clarify task member id types in references (#777)
|
||||
|
||||
## [v1.0.26] - 2026-05-08
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add `message_app_link` to message outputs (#668)
|
||||
- **auth**: Add scope hint for missing authorization errors (#776)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Clean error detail output (#783)
|
||||
- **whiteboard**: Reclassify `+update` as `write` risk (#775)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Add data integrity and write-confirmation rules (#749)
|
||||
|
||||
## [v1.0.25] - 2026-05-07
|
||||
|
||||
### Features
|
||||
|
||||
- Add skills version drift notice and unify update flow (#723)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove misleading default value from `--as` flag help text (#769)
|
||||
- Handle negative truncate lengths (#744)
|
||||
- Reject invalid JSON pointer escapes (#741)
|
||||
- Migrate task shortcut errors to structured `output.Errorf`/`ErrValidation` (#740)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Clarify base `user_open_id` guidance (#763)
|
||||
|
||||
## [v1.0.24] - 2026-05-06
|
||||
|
||||
### Features
|
||||
@@ -1265,40 +597,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
||||
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
||||
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
|
||||
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
|
||||
[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41
|
||||
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
|
||||
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
|
||||
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38
|
||||
[v1.0.37]: https://github.com/larksuite/cli/releases/tag/v1.0.37
|
||||
[v1.0.36]: https://github.com/larksuite/cli/releases/tag/v1.0.36
|
||||
[v1.0.35]: https://github.com/larksuite/cli/releases/tag/v1.0.35
|
||||
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
|
||||
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
|
||||
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
|
||||
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31
|
||||
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30
|
||||
[v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29
|
||||
[v1.0.28]: https://github.com/larksuite/cli/releases/tag/v1.0.28
|
||||
[v1.0.27]: https://github.com/larksuite/cli/releases/tag/v1.0.27
|
||||
[v1.0.26]: https://github.com/larksuite/cli/releases/tag/v1.0.26
|
||||
[v1.0.25]: https://github.com/larksuite/cli/releases/tag/v1.0.25
|
||||
[v1.0.24]: https://github.com/larksuite/cli/releases/tag/v1.0.24
|
||||
[v1.0.23]: https://github.com/larksuite/cli/releases/tag/v1.0.23
|
||||
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
|
||||
|
||||
83
Makefile
83
Makefile
@@ -5,27 +5,10 @@ BINARY := lark-cli
|
||||
MODULE := github.com/larksuite/cli
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
DATE := $(shell date +%Y-%m-%d)
|
||||
NODE ?= node
|
||||
QUALITY_GATE_CHANGED_FROM ?= $(shell bash scripts/resolve-changed-from.sh)
|
||||
QUALITY_GATE_CHANGED_FROM_RESOLVED = $(if $(strip $(QUALITY_GATE_CHANGED_FROM)),$(QUALITY_GATE_CHANGED_FROM),$(shell bash scripts/resolve-changed-from.sh))
|
||||
QUALITY_GATE_DIR ?= .tmp/quality-gate
|
||||
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
|
||||
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
|
||||
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
|
||||
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
|
||||
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
|
||||
# over `go env GOARCH`, because command-line make variables are not visible to
|
||||
# $(shell ...).
|
||||
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
|
||||
|
||||
all: test
|
||||
.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta
|
||||
|
||||
fetch_meta:
|
||||
python3 scripts/fetch_meta.py
|
||||
@@ -36,63 +19,13 @@ build: fetch_meta
|
||||
vet: fetch_meta
|
||||
go vet ./...
|
||||
|
||||
# fmt-check fails when any file would be reformatted by gofmt. Keep this
|
||||
# in sync with the fast-gate "Check formatting" step in CI.
|
||||
fmt-check:
|
||||
@unformatted=$$(gofmt -l . | grep -v '^\.claude/' || true); \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
echo "Unformatted Go files:"; \
|
||||
echo "$$unformatted"; \
|
||||
echo "Run 'gofmt -w .' and commit."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
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/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/...
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
# breaks, the plugin author guide's "go build ./..." path is broken.
|
||||
examples-build:
|
||||
go build ./extension/platform/examples/audit-observer
|
||||
go build ./extension/platform/examples/readonly-policy
|
||||
go test -race -gcflags="all=-N -l" -count=1 ./cmd/... ./internal/... ./shortcuts/...
|
||||
|
||||
integration-test: build
|
||||
go test -v -count=1 ./tests/...
|
||||
|
||||
test: vet fmt-check script-test unit-test examples-build integration-test
|
||||
|
||||
quality-gate: build
|
||||
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
|
||||
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
|
||||
LARKSUITE_CLI_REMOTE_META=off \
|
||||
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
|
||||
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
|
||||
go run ./internal/qualitygate/cmd/manifest-export \
|
||||
--manifest-out $(QUALITY_GATE_MANIFEST_OUT) \
|
||||
--command-index-out $(QUALITY_GATE_COMMAND_INDEX_OUT)
|
||||
LARKSUITE_CLI_APP_ID=dry-run \
|
||||
LARKSUITE_CLI_APP_SECRET=dry-run \
|
||||
LARKSUITE_CLI_BRAND=feishu \
|
||||
LARKSUITE_CLI_CONFIG_DIR=$${TMPDIR:-/tmp}/quality-gate-cli-config \
|
||||
LARKSUITE_CLI_REMOTE_META=off \
|
||||
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
|
||||
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
|
||||
go run ./internal/qualitygate/cmd/quality-gate check \
|
||||
--repo . \
|
||||
--cli-bin ./$(BINARY) \
|
||||
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
|
||||
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
|
||||
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
|
||||
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
|
||||
--facts-out $(QUALITY_GATE_FACTS_OUT)
|
||||
test: vet unit-test integration-test
|
||||
|
||||
install: build
|
||||
install -d $(PREFIX)/bin
|
||||
@@ -104,13 +37,3 @@ uninstall:
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
# Run secret-leak checks locally before pushing.
|
||||
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
|
||||
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
|
||||
# Step 2: gitleaks scans the full repo for real leaked secrets.
|
||||
# Install gitleaks: https://github.com/gitleaks/gitleaks#installing
|
||||
gitleaks:
|
||||
@bash scripts/check-doc-tokens.sh
|
||||
@command -v gitleaks >/dev/null 2>&1 || { echo "gitleaks not found. Install: brew install gitleaks"; exit 1; }
|
||||
gitleaks detect --redact -v --exit-code=2
|
||||
|
||||
33
README.md
33
README.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 26 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 18 business domains, 200+ curated commands, 26 AI Agent [Skills](./skills/)
|
||||
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -24,11 +24,11 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
|
||||
| Category | Capabilities |
|
||||
| ------------- |-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 📅 Calendar | View, create and update events, invite attendees, find meeting rooms, RSVP to invitations, check free/busy & time suggestions |
|
||||
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📝 Markdown | Create, fetch, patch, and overwrite Drive-native `.md` files |
|
||||
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
@@ -36,12 +36,11 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes artifacts and recordings |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 🕐 Attendance | Query personal attendance check-in records |
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
||||
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
||||
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
@@ -63,7 +62,11 @@ Choose **one** of the following methods:
|
||||
**Option 1 — From npm (recommended):**
|
||||
|
||||
```bash
|
||||
npx @larksuite/cli@latest install
|
||||
# Install CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
**Option 2 — From source:**
|
||||
@@ -99,7 +102,11 @@ lark-cli calendar +agenda
|
||||
**Step 1 — Install**
|
||||
|
||||
```bash
|
||||
npx @larksuite/cli@latest install
|
||||
# Install CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
**Step 2 — Configure app credentials**
|
||||
@@ -129,11 +136,11 @@ lark-cli auth status
|
||||
| Skill | Description |
|
||||
| ------------------------------- |----------------------------------------------------------------------------------------------------------------|
|
||||
| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) |
|
||||
| `lark-calendar` | Calendar events (create/update), agenda view, free/busy queries, time suggestions, room finding, RSVP replies |
|
||||
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-markdown` | Create, fetch, patch, and overwrite Drive-native Markdown files |
|
||||
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
@@ -144,7 +151,7 @@ lark-cli auth status
|
||||
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
|
||||
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
|
||||
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters); upload audio/video to create minutes, download media |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
|
||||
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-attendance` | Query personal attendance check-in records |
|
||||
@@ -198,7 +205,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
```
|
||||
|
||||
Run `lark-cli <service> --help` to see all shortcut commands.
|
||||
@@ -279,8 +286,6 @@ Community contributions are welcome! If you find a bug or have feature suggestio
|
||||
|
||||
For major changes, we recommend discussing with us first via an Issue.
|
||||
|
||||
Before opening a PR, see [AGENTS.md](./AGENTS.md) for the local build, test, and PR checklist used by contributors and AI agents.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **MIT License**.
|
||||
|
||||
35
README.zh.md
35
README.zh.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 26 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — 26 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 18 大业务域、200+ 精选命令、26 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -24,11 +24,11 @@
|
||||
|
||||
| 类别 | 能力 |
|
||||
| ------------- |--------------------------------------------|
|
||||
| 📅 日历 | 查看、创建和更新日程,邀请参会人、查找会议室、回复日程邀请、查询忙闲与时间建议 |
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📝 Markdown | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 `.md` 文件 |
|
||||
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
@@ -36,12 +36,11 @@
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要产物与会议录制 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🔗 应用 | 创建妙搭(Spark/Miaoda)应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -63,7 +62,11 @@
|
||||
**方式一 — 从 npm 安装(推荐):**
|
||||
|
||||
```bash
|
||||
npx @larksuite/cli@latest install
|
||||
# 安装 CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
**方式二 — 从源码安装:**
|
||||
@@ -99,7 +102,11 @@ lark-cli calendar +agenda
|
||||
**第 1 步 — 安装**
|
||||
|
||||
```bash
|
||||
npx @larksuite/cli@latest install
|
||||
# 安装 CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
**第 2 步 — 配置应用凭证**
|
||||
@@ -130,11 +137,11 @@ lark-cli auth status
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- |-------------------------------------------|
|
||||
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
|
||||
| `lark-calendar` | 日历日程(创建/更新)、议程查看、忙闲查询、时间建议、会议室查找、回复邀请 |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-markdown` | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 Markdown 文件 |
|
||||
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
@@ -145,7 +152,7 @@ lark-cli auth status
|
||||
| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 |
|
||||
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
|
||||
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节),上传音视频生成妙记,下载音视频文件 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-attendance` | 查询个人考勤打卡记录 |
|
||||
@@ -199,7 +206,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
```
|
||||
|
||||
运行 `lark-cli <service> --help` 查看所有快捷命令。
|
||||
@@ -280,8 +287,6 @@ lark-cli schema im.messages.delete
|
||||
|
||||
对于较大的改动,建议先通过 Issue 与我们讨论。
|
||||
|
||||
提交 PR 前,请先阅读 [AGENTS.md](./AGENTS.md),其中列出了贡献者和 AI Agent 使用的本地构建、测试和 PR 检查清单。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 **MIT 许可证** 开源。
|
||||
|
||||
107
cmd/api/api.go
107
cmd/api/api.go
@@ -10,7 +10,6 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -91,7 +90,6 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
||||
@@ -105,7 +103,6 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -124,13 +121,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
|
||||
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--params and --data cannot both read from stdin (-)").
|
||||
WithHint("pass at most one flag as '-'; give the other inline JSON or @file").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--params", Reason: "reads from stdin (-)"},
|
||||
errs.InvalidParam{Name: "--data", Reason: "reads from stdin (-)"},
|
||||
)
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
@@ -160,10 +151,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--data must be a JSON object when used with --file").
|
||||
WithHint(`with --file, --data carries multipart form fields, e.g. --data '{"image_type":"message"}'`).
|
||||
WithParam("--data")
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,13 +194,7 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output and --page-all are mutually exclusive").
|
||||
WithHint("drop --page-all to save a binary response, or drop --output to paginate JSON").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--output", Reason: "conflicts with --page-all"},
|
||||
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
|
||||
)
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
@@ -249,17 +231,13 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll {
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
|
||||
}
|
||||
|
||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||
if err != nil {
|
||||
// MarkRaw tells the dispatcher to skip the legacy enrichPermissionError
|
||||
// pass on *output.ExitError values. Typed *errs.* errors that flow
|
||||
// through here keep their canonical message / hint from BuildAPIError;
|
||||
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
|
||||
return errs.MarkRaw(err)
|
||||
return output.MarkRaw(client.WrapDoAPIError(err))
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
@@ -269,17 +247,11 @@ func apiRun(opts *APIOptions) error {
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
// CheckResponse routes through errclass.BuildAPIError for known Lark
|
||||
// codes (typed PermissionError / AuthenticationError / ...). For
|
||||
// unknown codes it falls back to *errs.APIError. The Brand+AppID on
|
||||
// the client populate identity-aware fields (ConsoleURL etc.).
|
||||
CheckError: ac.CheckResponse,
|
||||
})
|
||||
// MarkRaw: see comment above on the DoAPI path. Skips legacy
|
||||
// *ExitError enrichment; typed errors flow through unchanged.
|
||||
// MarkRaw tells root error handler to skip enrichPermissionError,
|
||||
// preserving the original API error detail (log_id, troubleshooter, etc.).
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -288,76 +260,43 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return errs.MarkRaw(apiErr)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
JqExpr: jqExpr,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
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)
|
||||
}
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
return errs.MarkRaw(apiErr)
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
if !hasItems {
|
||||
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return errs.MarkRaw(apiErr)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
output.FormatValue(out, result, format)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,16 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -69,24 +66,6 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: --params null parses to a nil map; writing page_size onto it must
|
||||
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
|
||||
// write into the map ParseJSONMap returns.
|
||||
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, "page_size") {
|
||||
t.Errorf("expected page_size applied over null --params, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_BotMode(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -104,19 +83,8 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got["ok"] != true || got["identity"] != "bot" {
|
||||
t.Fatalf("unexpected envelope: %#v", got)
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok || data["result"] != "success" {
|
||||
t.Fatalf("data = %#v, want result=success", got["data"])
|
||||
if !strings.Contains(stdout.String(), "success") {
|
||||
t.Error("expected 'success' in output")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,16 +310,8 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
t.Error("expected 'falling back to json' in stderr")
|
||||
}
|
||||
// Should output JSON result to stdout
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
|
||||
t.Fatalf("unexpected fallback envelope: %#v", got)
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
|
||||
if !strings.Contains(stdout.String(), "u123") {
|
||||
t.Error("expected user_id in JSON output")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +324,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230027, "msg": "user not authorized",
|
||||
"code": 230001, "msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -376,20 +336,12 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
t.Fatal("expected an error for non-zero code")
|
||||
}
|
||||
// Should still output the response body so user can see the error details
|
||||
if !strings.Contains(stdout.String(), "230027") {
|
||||
if !strings.Contains(stdout.String(), "230001") {
|
||||
t.Errorf("expected error response in stdout, got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "user not authorized") {
|
||||
if !strings.Contains(stdout.String(), "no permission") {
|
||||
t.Errorf("expected error message in stdout, got: %s", stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
|
||||
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
@@ -425,274 +377,6 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230027, "msg": "user not authorized",
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code on later page")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "safe-page") {
|
||||
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
|
||||
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "\n \"code\"") {
|
||||
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if got["ok"] != true || got["identity"] != "bot" || !ok {
|
||||
t.Fatalf("unexpected envelope: %#v", got)
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
|
||||
}
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("data.items = %#v, want one item", data["items"])
|
||||
}
|
||||
}
|
||||
|
||||
type apiContentSafetyProvider struct {
|
||||
called bool
|
||||
path string
|
||||
data interface{}
|
||||
match string
|
||||
}
|
||||
|
||||
func (p *apiContentSafetyProvider) Name() string { return "api-test" }
|
||||
|
||||
func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
p.called = true
|
||||
p.path = req.Path
|
||||
p.data = req.Data
|
||||
if p.match != "" {
|
||||
b, _ := json.Marshal(req.Data)
|
||||
if !strings.Contains(string(b), p.match) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
provider := &apiContentSafetyProvider{}
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !provider.called {
|
||||
t.Fatal("expected content safety provider to scan paginated output")
|
||||
}
|
||||
if provider.path != "api" {
|
||||
t.Fatalf("scan path = %q, want api", provider.path)
|
||||
}
|
||||
data, ok := provider.data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("scanned data type = %T, want map", provider.data)
|
||||
}
|
||||
if _, hasCode := data["code"]; hasCode {
|
||||
t.Fatalf("scanned data should be business data only, got %#v", data)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
alert, ok := got["_content_safety_alert"].(map[string]interface{})
|
||||
if !ok || alert["provider"] != "api-test" {
|
||||
t.Fatalf("missing content safety alert in envelope: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
provider := &apiContentSafetyProvider{}
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !provider.called {
|
||||
t.Fatal("expected content safety provider to scan streamed paginated output")
|
||||
}
|
||||
if provider.path != "api" {
|
||||
t.Fatalf("scan path = %q, want api", provider.path)
|
||||
}
|
||||
items, ok := provider.data.([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
|
||||
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"id":"1"`) {
|
||||
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
provider := &apiContentSafetyProvider{match: "blocked"}
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected content safety block error")
|
||||
}
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(err, &safetyErr) {
|
||||
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
|
||||
}
|
||||
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
|
||||
}
|
||||
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
|
||||
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "safe-page") {
|
||||
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "blocked-page") {
|
||||
t.Fatalf("blocked page was written before safety block: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
|
||||
t.Helper()
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype || p.Code != code {
|
||||
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
@@ -715,6 +399,154 @@ func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_APIError_IsRaw(t *testing.T) {
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Return a permission error from the API
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/perm", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for permission denied API response")
|
||||
}
|
||||
|
||||
// Error should be marked Raw
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if !exitErr.Raw {
|
||||
t.Error("expected API error from api command to be marked Raw")
|
||||
}
|
||||
|
||||
// Note: stderr envelope output is tested at the root level (TestHandleRootError_*)
|
||||
// since WriteErrorEnvelope is called by handleRootError, not by cobra's Execute.
|
||||
_ = stderr
|
||||
}
|
||||
|
||||
func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/origmsg",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:message:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/origmsg", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
// The message should NOT have been enriched (no "App scope not enabled" replacement)
|
||||
if strings.Contains(exitErr.Error(), "App scope not enabled") {
|
||||
t.Error("expected original message, not enriched message")
|
||||
}
|
||||
// Detail should still contain the raw API error detail
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil Detail")
|
||||
}
|
||||
if exitErr.Detail.Detail == nil {
|
||||
t.Error("expected raw Detail.Detail to be preserved (not cleared by enrichment)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/invalidjson",
|
||||
RawBody: []byte{},
|
||||
ContentType: "application/json",
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected detail on exit error")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
|
||||
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
|
||||
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--output") {
|
||||
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/rawpage",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled",
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/rawpage", "--as", "bot", "--page-all"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if !exitErr.Raw {
|
||||
t.Error("expected paginated API error to be marked Raw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -988,69 +820,3 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApiCmd_PermissionError_DerivesFirstClassFields pins that when a Lark
|
||||
// API returns a missing-scope failure, the typed *errs.PermissionError
|
||||
// surfaced by `lark-cli api` lifts the diagnostic signals BuildAPIError
|
||||
// consumed during classification into first-class wire fields
|
||||
// (MissingScopes, LogID, ConsoleURL). The wire shape is the typed envelope
|
||||
// — there is no raw-payload passthrough; new Lark diagnostic fields require
|
||||
// a CLI release.
|
||||
func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_test_perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/docx/v1/documents/test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991679,
|
||||
"msg": "scope missing",
|
||||
"log_id": "20260527-test-log",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "docx:document"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "docx:document" {
|
||||
t.Errorf("MissingScopes = %v, want [docx:document]", pe.MissingScopes)
|
||||
}
|
||||
if pe.LogID != "20260527-test-log" {
|
||||
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("--json should be accepted without error, got: %v", err)
|
||||
}
|
||||
if gotOpts.Method != "GET" {
|
||||
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
)
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
@@ -44,7 +43,6 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthList(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthCheck(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -71,7 +69,7 @@ func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (ope
|
||||
|
||||
var resp userInfoResponse
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse user info: %w", err)
|
||||
return "", "", fmt.Errorf("failed to parse user info: %v", err)
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
|
||||
@@ -111,11 +109,6 @@ type appInfoResponse struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// getAppInfoFn is the package-level seam used by callers (scopes.go) so tests
|
||||
// can substitute a fake without standing up a full SDK + httpmock pipeline.
|
||||
// Mirrors the pollDeviceToken pattern in login.go.
|
||||
var getAppInfoFn = getAppInfo
|
||||
|
||||
// getAppInfo queries app info from the Lark API.
|
||||
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||
ac, err := f.NewAPIClient()
|
||||
@@ -137,10 +130,10 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
||||
|
||||
var resp appInfoResponse
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return nil, classifyAppInfoErr(apiResp.RawBody, resp.Code, resp.Msg, f, appId)
|
||||
return nil, fmt.Errorf("API error [%d]: %s", resp.Code, resp.Msg)
|
||||
}
|
||||
|
||||
app := resp.Data.App
|
||||
@@ -159,21 +152,3 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
||||
|
||||
return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil
|
||||
}
|
||||
|
||||
// classifyAppInfoErr re-decodes the raw body so BuildAPIError sees the
|
||||
// upstream `error` block — the typed appInfoResponse shape drops it.
|
||||
func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory, appId string) error {
|
||||
var raw map[string]any
|
||||
_ = json.Unmarshal(rawBody, &raw)
|
||||
if raw == nil {
|
||||
raw = map[string]any{}
|
||||
}
|
||||
raw["code"] = code
|
||||
raw["msg"] = msg
|
||||
cc := errclass.ClassifyContext{Identity: string(core.AsBot)}
|
||||
if cfg, _ := f.Config(); cfg != nil {
|
||||
cc.Brand = string(cfg.Brand)
|
||||
cc.AppID = appId
|
||||
}
|
||||
return errclass.BuildAPIError(raw, cc)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -45,32 +44,6 @@ func TestAuthLoginCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { return nil })
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"only delivers final turn messages",
|
||||
"--no-wait --json",
|
||||
"send the verification URL (or QR code) to the user as your final message",
|
||||
"run --device-code in a later step",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("help missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -91,29 +64,6 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *CheckOptions
|
||||
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -132,27 +82,6 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *LogoutOptions
|
||||
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -170,27 +99,6 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ListOptions
|
||||
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -210,29 +118,6 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -355,32 +240,6 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *ScopesOptions
|
||||
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--format", "pretty", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
if gotOpts.Format != "json" {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
@@ -433,54 +292,6 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError pins that when
|
||||
// the Lark API returns a permission code (99991679 with permission_violations),
|
||||
// getAppInfo classifies it as *errs.PermissionError carrying the server-
|
||||
// supplied MissingScopes — not a bare error wrapped as InternalError.
|
||||
func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/application/v6/applications/test-app",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991679,
|
||||
"msg": "scope missing",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "application:application:self_manage"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := authScopesRun(&ScopesOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Format: "json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "application:application:self_manage" {
|
||||
t.Errorf("MissingScopes = %v, want server-supplied [application:application:self_manage]", pe.MissingScopes)
|
||||
}
|
||||
|
||||
var intErr *errs.InternalError
|
||||
if errors.As(err, &intErr) {
|
||||
t.Error("Lark business error must not be wrapped as InternalError; permission semantics lost")
|
||||
}
|
||||
}
|
||||
|
||||
type authScopesTokenResolver struct {
|
||||
requests []credential.TokenSpec
|
||||
}
|
||||
@@ -552,8 +363,15 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
|
||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||
}
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
|
||||
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
type CheckOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Scope string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthCheck creates the auth check subcommand.
|
||||
@@ -38,9 +36,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.MarkFlagRequired("scope")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -50,7 +46,8 @@ func authCheckRun(opts *CheckOptions) error {
|
||||
|
||||
required := strings.Fields(opts.Scope)
|
||||
if len(required) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be empty").WithParam("--scope")
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}})
|
||||
return nil
|
||||
}
|
||||
|
||||
config, err := f.Config()
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// `lark-cli auth check` is a predicate command: its README contract is
|
||||
// `exit 0 = ok, 1 = missing`. The JSON answer goes to stdout; stderr stays
|
||||
// empty so callers can write `if lark-cli auth check ...; then ... fi`
|
||||
// without their logs getting polluted by an error envelope on the negative
|
||||
// branch. These tests pin that contract end-to-end through the dispatcher.
|
||||
|
||||
func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
// UserOpenId left empty: triggers the not_logged_in branch.
|
||||
})
|
||||
|
||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
|
||||
|
||||
if got := output.ExitCodeOf(err); got != 1 {
|
||||
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
|
||||
}
|
||||
var bare *output.BareError
|
||||
if !errors.As(err, &bare) {
|
||||
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
|
||||
}
|
||||
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty for predicate negative answer, 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 payload["ok"] != false {
|
||||
t.Errorf("stdout.ok = %v, want false", payload["ok"])
|
||||
}
|
||||
if payload["error"] != "not_logged_in" {
|
||||
t.Errorf("stdout.error = %v, want 'not_logged_in'", payload["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) {
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_user", UserName: "tester",
|
||||
})
|
||||
|
||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
|
||||
|
||||
if got := output.ExitCodeOf(err); got != 1 {
|
||||
t.Errorf("exit code = %d, want 1", got)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("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", err)
|
||||
}
|
||||
if payload["ok"] != false {
|
||||
t.Errorf("stdout.ok = %v, want false", payload["ok"])
|
||||
}
|
||||
if payload["error"] != "no_token" {
|
||||
t.Errorf("stdout.error = %v, want 'no_token'", payload["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
|
||||
// Predicate command happy path: stored token covers every required
|
||||
// scope. Exit must be 0 (nil error, not ErrBare), stdout carries the
|
||||
// `{"ok":true,...}` JSON answer, and stderr stays empty so shell
|
||||
// callers can rely on `if lark-cli auth check ...; then` without log
|
||||
// pollution. Pairs with the two exit-1 negatives above so both
|
||||
// branches of the predicate contract are pinned.
|
||||
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 docx:document",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: "im:message"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for happy path (exit 0), got %v", err)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != 0 {
|
||||
t.Errorf("exit code = %d, want 0", got)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty for predicate exit-0 answer, 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 payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
granted, ok := payload["granted"].([]any)
|
||||
if !ok || len(granted) != 1 || granted[0] != "im:message" {
|
||||
t.Errorf("stdout.granted = %v, want [im:message]", payload["granted"])
|
||||
}
|
||||
if payload["missing"] != nil {
|
||||
t.Errorf("stdout.missing = %v, want nil/absent on happy path", payload["missing"])
|
||||
}
|
||||
if _, has := payload["suggestion"]; has {
|
||||
t.Errorf("stdout.suggestion must be absent on happy path; got %v", payload["suggestion"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
|
||||
// Scope validation is a real input error, not a predicate negative
|
||||
// answer — it must surface as a typed ValidationError with the normal
|
||||
// stderr envelope, distinct from the silent ErrBare predicate path.
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: " "})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty --scope")
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
// ListOptions holds all inputs for auth list.
|
||||
type ListOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthList creates the auth list subcommand.
|
||||
@@ -36,8 +34,6 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
return authListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -47,20 +43,12 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// auth list is a read-only probe; the "configured but no users"
|
||||
// branch below already returns exit 0 with a stderr hint, so we
|
||||
// keep the same contract here. We still want the hint to be
|
||||
// workspace-aware, so we pull the message+hint out of
|
||||
// NotConfiguredError() instead of hard-coding it.
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||
if cfgErr.Hint != "" {
|
||||
@@ -72,14 +60,6 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -35,33 +34,6 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||
@@ -85,48 +57,3 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
|
||||
t.Errorf("agent hint must not mention config init: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,9 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
@@ -33,7 +30,6 @@ type LoginOptions struct {
|
||||
Scope string
|
||||
Recommend bool
|
||||
Domains []string
|
||||
Exclude []string
|
||||
NoWait bool
|
||||
DeviceCode string
|
||||
}
|
||||
@@ -50,15 +46,12 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
|
||||
Long: `Device Flow authorization login.
|
||||
|
||||
For AI agents: this command blocks until the user completes authorization in the
|
||||
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
|
||||
send the verification URL (or QR code) to the user as your final message, end the turn, then
|
||||
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
|
||||
to generate QR codes (supports ASCII and PNG formats).`,
|
||||
browser. Run it in the background and retrieve the verification URL from its output.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"strict mode is %q, user login is disabled in this profile", mode).
|
||||
WithHint("if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
|
||||
"if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
@@ -68,21 +61,12 @@ to generate QR codes (supports ASCII and PNG formats).`,
|
||||
},
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
var helpBrand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
helpBrand = cfg.Brand
|
||||
}
|
||||
}
|
||||
available := sortedKnownDomains(helpBrand)
|
||||
available := sortedKnownDomains()
|
||||
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
|
||||
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
|
||||
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
|
||||
"scopes to exclude from the request (repeatable or comma-separated, e.g. --exclude drive:file:download)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
|
||||
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
|
||||
@@ -124,7 +108,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Determine UI language from saved config
|
||||
var lang i18n.Lang
|
||||
lang := "zh"
|
||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
|
||||
if app := multi.FindApp(config.ProfileName); app != nil {
|
||||
lang = app.Lang
|
||||
@@ -149,43 +133,39 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
// Expand --domain all to all available domains (from_meta projects + shortcut services)
|
||||
for _, d := range selectedDomains {
|
||||
if strings.EqualFold(d, "all") {
|
||||
selectedDomains = sortedKnownDomains(config.Brand)
|
||||
selectedDomains = sortedKnownDomains()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domain names and suggest corrections for unknown ones
|
||||
if len(selectedDomains) > 0 {
|
||||
knownDomains := allKnownDomains(config.Brand)
|
||||
knownDomains := allKnownDomains()
|
||||
for _, d := range selectedDomains {
|
||||
if !knownDomains[d] {
|
||||
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
|
||||
return output.ErrValidation("unknown domain %q, did you mean %q?", d, suggestion)
|
||||
}
|
||||
available := make([]string, 0, len(knownDomains))
|
||||
for k := range knownDomains {
|
||||
available = append(available, k)
|
||||
}
|
||||
sort.Strings(available)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
|
||||
return output.ErrValidation("unknown domain %q, available domains: %s", d, strings.Join(available, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
|
||||
|
||||
if len(opts.Exclude) > 0 && !hasAnyOption {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude")
|
||||
}
|
||||
|
||||
if !hasAnyOption {
|
||||
if !opts.JSON && f.IOStreams.IsTerminal {
|
||||
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
|
||||
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected")
|
||||
return output.ErrValidation("no login options selected")
|
||||
}
|
||||
selectedDomains = result.Domains
|
||||
scopeLevel = result.ScopeLevel
|
||||
@@ -200,28 +180,25 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
log("View all options:")
|
||||
log(msg.HintFooter)
|
||||
log("")
|
||||
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope")
|
||||
log("Note: this command blocks until authorization is complete. Run it in the background and retrieve the verification URL from its output.")
|
||||
return output.ErrValidation("please specify the scopes to authorize")
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize --scope so users can pass either OAuth-standard space-separated
|
||||
// values or the more natural comma-separated list. RFC 6749 §3.3 mandates
|
||||
// space-delimited scopes in the wire request, so the device authorization
|
||||
// endpoint rejects raw "a,b" strings as a single malformed scope.
|
||||
finalScope := normalizeScopeInput(opts.Scope)
|
||||
finalScope := opts.Scope
|
||||
|
||||
// Resolve scopes from domain/permission filters and merge with --scope.
|
||||
// --scope, --domain, and --recommend combine additively so callers can,
|
||||
// for example, request all `docs` scopes plus a few specific `drive`
|
||||
// scopes in a single command.
|
||||
// Resolve scopes from domain/permission filters
|
||||
if len(selectedDomains) > 0 || opts.Recommend {
|
||||
if opts.Scope != "" {
|
||||
return output.ErrValidation("cannot use --scope together with --domain/--recommend")
|
||||
}
|
||||
|
||||
var candidateScopes []string
|
||||
if len(selectedDomains) > 0 {
|
||||
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
|
||||
candidateScopes = collectScopesForDomains(selectedDomains, "user")
|
||||
} else {
|
||||
// --recommend without --domain: all domains
|
||||
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
|
||||
candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user")
|
||||
}
|
||||
|
||||
// Filter to auto-approve scopes if --recommend or interactive "common"
|
||||
@@ -229,35 +206,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
candidateScopes = registry.FilterAutoApproveScopes(candidateScopes)
|
||||
}
|
||||
|
||||
if len(candidateScopes) == 0 && opts.Scope == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options")
|
||||
if len(candidateScopes) == 0 {
|
||||
return output.ErrValidation("no matching scopes found, check domain/scope options")
|
||||
}
|
||||
|
||||
// Merge --scope additively with the resolved domain scopes.
|
||||
merged := make(map[string]bool, len(candidateScopes)+len(strings.Fields(finalScope)))
|
||||
for _, s := range candidateScopes {
|
||||
merged[s] = true
|
||||
}
|
||||
for _, s := range strings.Fields(finalScope) {
|
||||
merged[s] = true
|
||||
}
|
||||
finalScope = joinSortedScopeSet(merged)
|
||||
}
|
||||
|
||||
// Apply --exclude on top of the resolved scope set. We honour exclude
|
||||
// regardless of whether scopes came from --scope, --domain, --recommend,
|
||||
// or any combination thereof.
|
||||
if len(opts.Exclude) > 0 {
|
||||
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
|
||||
if len(unknown) > 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"these --exclude scopes are not present in the requested set: %s",
|
||||
strings.Join(unknown, ", ")).WithParam("--exclude")
|
||||
}
|
||||
finalScope = excluded
|
||||
if strings.TrimSpace(finalScope) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no scopes left after applying --exclude; nothing to authorize").WithParam("--exclude")
|
||||
}
|
||||
finalScope = strings.Join(candidateScopes, " ")
|
||||
}
|
||||
|
||||
// Step 1: Request device authorization
|
||||
@@ -267,7 +220,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
|
||||
return output.ErrAuth("device authorization failed: %v", err)
|
||||
}
|
||||
|
||||
// --no-wait: return immediately with device code and URL
|
||||
@@ -279,28 +232,21 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"hint": "**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it." +
|
||||
"**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it." +
|
||||
"**Display order:** Output the URL first, then place the QR code image below the URL." +
|
||||
"**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation." +
|
||||
"For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" +
|
||||
"**After the user confirms authorization:** YOU must execute `lark-cli auth login --device-code <device_code>` yourself." +
|
||||
"**Do NOT cache verification_url or device_code for future use.** Always run `lark-cli auth login --no-wait --json` fresh when authorization is needed.",
|
||||
"hint": fmt.Sprintf("Show verification_url to user, then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL.
|
||||
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
|
||||
// capture stdout into a JSON parser see it without stream-mixing surprises.
|
||||
// Text mode prints the hint to stderr only when running under a non-TTY
|
||||
// (i.e. piped / agent harness), since humans reading a terminal don't need
|
||||
// the agent-oriented instructions.
|
||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
||||
// JSON mode embeds it as a structured field (so an agent that captures
|
||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
||||
// text mode prints to stderr (alongside the URL prompt).
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -313,14 +259,12 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -336,25 +280,25 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"event": "authorization_failed",
|
||||
"error": result.Message,
|
||||
}); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message)
|
||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||
}
|
||||
if result.Token == nil {
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned")
|
||||
return output.ErrAuth("authorization succeeded but no token returned")
|
||||
}
|
||||
|
||||
// Step 6: Get user info
|
||||
log(msg.AuthSuccess)
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err)
|
||||
return output.ErrAuth("failed to get SDK: %v", err)
|
||||
}
|
||||
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
||||
if err != nil {
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err)
|
||||
return output.ErrAuth("failed to get user info: %v", err)
|
||||
}
|
||||
|
||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
|
||||
@@ -372,13 +316,13 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
GrantedAt: now,
|
||||
}
|
||||
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save token: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
|
||||
}
|
||||
|
||||
// Step 8: Update config — overwrite Users to single user, clean old tokens
|
||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||
return err
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
@@ -407,11 +351,10 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Skip the stderr hint in JSON mode (the --no-wait call that issued
|
||||
// the device_code already surfaced it as a JSON field), and also skip it
|
||||
// when running on an interactive terminal — the agent-oriented
|
||||
// instructions only matter for piped / harness environments.
|
||||
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
|
||||
// device_code already returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
@@ -422,22 +365,22 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
if shouldRemoveLoginRequestedScope(result) {
|
||||
cleanupRequestedScope()
|
||||
}
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message)
|
||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||
}
|
||||
defer cleanupRequestedScope()
|
||||
if result.Token == nil {
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned")
|
||||
return output.ErrAuth("authorization succeeded but no token returned")
|
||||
}
|
||||
|
||||
// Get user info
|
||||
log(msg.AuthSuccess)
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err)
|
||||
return output.ErrAuth("failed to get SDK: %v", err)
|
||||
}
|
||||
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
||||
if err != nil {
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err)
|
||||
return output.ErrAuth("failed to get user info: %v", err)
|
||||
}
|
||||
|
||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
|
||||
@@ -455,13 +398,13 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
GrantedAt: now,
|
||||
}
|
||||
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save token: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
|
||||
}
|
||||
|
||||
// Update config — overwrite Users to single user, clean old tokens
|
||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to update login profile: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
@@ -472,22 +415,21 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncLoginUserToProfile persists the logged-in user info into the named profile.
|
||||
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err)
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
app := findProfileByName(multi, profileName)
|
||||
if app == nil {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName)
|
||||
return fmt.Errorf("profile %q not found in config", profileName)
|
||||
}
|
||||
|
||||
oldUsers := append([]core.AppUser(nil), app.Users...)
|
||||
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err)
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
for _, oldUser := range oldUsers {
|
||||
@@ -498,7 +440,6 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// findProfileByName returns the AppConfig matching profileName, or nil.
|
||||
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].ProfileName() == profileName {
|
||||
@@ -512,7 +453,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
|
||||
// shortcut scopes for the given domain names.
|
||||
// Domains with auth_domain children are automatically expanded to include
|
||||
// their children's scopes.
|
||||
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
|
||||
func collectScopesForDomains(domains []string, identity string) []string {
|
||||
scopeSet := make(map[string]bool)
|
||||
|
||||
// 1. API scopes from from_meta projects
|
||||
@@ -531,11 +472,8 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
||||
for _, s := range sc.ScopesForIdentity(identity) {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
}
|
||||
@@ -553,7 +491,7 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
|
||||
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
||||
// shortcut services), excluding domains that have auth_domain set (they are
|
||||
// folded into their parent domain).
|
||||
func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
func allKnownDomains() map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
if !registry.HasAuthDomain(p) {
|
||||
@@ -561,9 +499,6 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
}
|
||||
@@ -572,8 +507,8 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
}
|
||||
|
||||
// sortedKnownDomains returns all valid domain names sorted alphabetically.
|
||||
func sortedKnownDomains(brand core.LarkBrand) []string {
|
||||
m := allKnownDomains(brand)
|
||||
func sortedKnownDomains() []string {
|
||||
m := allKnownDomains()
|
||||
domains := make([]string, 0, len(m))
|
||||
for d := range m {
|
||||
domains = append(domains, d)
|
||||
@@ -597,40 +532,6 @@ func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeScopeInput accepts a user-supplied --scope value that may use
|
||||
// commas, spaces, tabs, or newlines (or any mix) as separators and returns the
|
||||
// canonical OAuth 2.0 wire form: a single space-joined string with empties
|
||||
// trimmed and duplicates removed (first occurrence wins; order preserved).
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "vc:note:read,vc:meeting.meetingevent:read" -> "vc:note:read vc:meeting.meetingevent:read"
|
||||
// "a, b , c" -> "a b c"
|
||||
// "a b a" -> "a b"
|
||||
// "" -> ""
|
||||
func normalizeScopeInput(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// Treat both commas and any whitespace as separators.
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
})
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
seen := make(map[string]struct{}, len(fields))
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if _, ok := seen[f]; ok {
|
||||
continue
|
||||
}
|
||||
seen[f] = struct{}{}
|
||||
out = append(out, f)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// suggestDomain finds the best "did you mean" match for an unknown domain.
|
||||
func suggestDomain(input string, known map[string]bool) string {
|
||||
// Check common cases: prefix match or input is a substring
|
||||
@@ -641,58 +542,3 @@ func suggestDomain(input string, known map[string]bool) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// joinSortedScopeSet returns a deterministic, space-separated scope string
|
||||
// from a set, sorted alphabetically. Empty/blank scopes are dropped.
|
||||
func joinSortedScopeSet(set map[string]bool) string {
|
||||
out := make([]string, 0, len(set))
|
||||
for s := range set {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// applyExcludeScopes removes the provided exclude entries from the requested
|
||||
// scope string. Each --exclude flag value may itself contain comma- or
|
||||
// whitespace-separated scopes. Returns the filtered scope string and any
|
||||
// exclude entries that were not present in the requested set (callers can
|
||||
// surface those as a validation error to catch typos like
|
||||
// `--exclude drive:file:downlod`).
|
||||
func applyExcludeScopes(requested string, excludes []string) (string, []string) {
|
||||
requestedSet := make(map[string]bool)
|
||||
for _, s := range strings.Fields(requested) {
|
||||
requestedSet[s] = true
|
||||
}
|
||||
|
||||
excludeSet := make(map[string]bool)
|
||||
for _, raw := range excludes {
|
||||
// --exclude already splits on commas (StringSliceVar), but also
|
||||
// tolerate whitespace-separated entries inside a single value.
|
||||
for _, s := range strings.Fields(strings.ReplaceAll(raw, ",", " ")) {
|
||||
excludeSet[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
var unknown []string
|
||||
for s := range excludeSet {
|
||||
if !requestedSet[s] {
|
||||
unknown = append(unknown, s)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
sort.Strings(unknown)
|
||||
return requested, unknown
|
||||
}
|
||||
|
||||
kept := make(map[string]bool, len(requestedSet))
|
||||
for s := range requestedSet {
|
||||
if !excludeSet[s] {
|
||||
kept[s] = true
|
||||
}
|
||||
}
|
||||
return joinSortedScopeSet(kept), nil
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
|
||||
feishuDomains := allKnownDomains(core.BrandFeishu)
|
||||
if !feishuDomains["apps"] {
|
||||
t.Errorf("expected apps domain to be known on Feishu brand")
|
||||
}
|
||||
|
||||
larkDomains := allKnownDomains(core.BrandLark)
|
||||
if larkDomains["apps"] {
|
||||
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
|
||||
}
|
||||
|
||||
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
|
||||
if len(feishuScopes) == 0 {
|
||||
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
|
||||
}
|
||||
|
||||
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
|
||||
if len(larkScopes) != 0 {
|
||||
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,7 @@ import (
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
@@ -92,17 +90,22 @@ func buildDomainMeta(name, lang string) domainMeta {
|
||||
Description: desc,
|
||||
}
|
||||
}
|
||||
// Fallback: read from the typed service spec (legacy)
|
||||
// Fallback: read from from_meta spec (legacy)
|
||||
meta := registry.LoadFromMeta(name)
|
||||
dm := domainMeta{Name: name}
|
||||
if svc, ok := registry.ServiceTyped(name); ok {
|
||||
dm.Title = svc.Title
|
||||
dm.Description = svc.Description
|
||||
if meta != nil {
|
||||
if t, ok := meta["title"].(string); ok {
|
||||
dm.Title = t
|
||||
}
|
||||
if d, ok := meta["description"].(string); ok {
|
||||
dm.Description = d
|
||||
}
|
||||
}
|
||||
return dm
|
||||
}
|
||||
|
||||
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
|
||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
|
||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) {
|
||||
allDomains := getDomainMetadata(lang)
|
||||
|
||||
// Build multi-select options
|
||||
@@ -158,11 +161,11 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra
|
||||
}
|
||||
|
||||
if len(selectedDomains) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain")
|
||||
return nil, output.ErrValidation("no domains selected")
|
||||
}
|
||||
|
||||
// Compute scope summary
|
||||
scopes := collectScopesForDomains(selectedDomains, "user", brand)
|
||||
scopes := collectScopesForDomains(selectedDomains, "user")
|
||||
if permLevel == "common" {
|
||||
scopes = registry.FilterAutoApproveScopes(scopes)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
package auth
|
||||
|
||||
import "github.com/larksuite/cli/internal/i18n"
|
||||
|
||||
type loginMsg struct {
|
||||
// Interactive UI (login_interactive.go)
|
||||
SelectDomains string
|
||||
@@ -61,7 +59,7 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output);仅当用户明确要求时才使用 ASCII(--ascii)。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL,再将二维码图片置于 URL 下方完整展示。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string,不要做任何修改(包括 URL 编码/解码、添加空格或标点)。",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout ≥ 600s;如不支持长 timeout,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询,**不要短 timeout 反复重试**——每次重启会作废上一轮的 device code,导致用户授权的链接失效。",
|
||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
@@ -97,7 +95,7 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation.",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is ≥ 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout** — each restart invalidates the previous device code, so any URL the user already authorized becomes useless.",
|
||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
@@ -116,9 +114,8 @@ var loginMsgEn = &loginMsg{
|
||||
HintFooter: " lark-cli auth login --help",
|
||||
}
|
||||
|
||||
// getLoginMsg returns the login message bundle for the given language.
|
||||
func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
if lang.IsEnglish() {
|
||||
func getLoginMsg(lang string) *loginMsg {
|
||||
if lang == "en" {
|
||||
return loginMsgEn
|
||||
}
|
||||
return loginMsgZh
|
||||
@@ -128,5 +125,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
return []string{"base", "contact", "docs", "markdown"}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
)
|
||||
|
||||
func TestGetLoginMsg_Zh(t *testing.T) {
|
||||
@@ -33,7 +31,7 @@ func TestGetLoginMsg_En(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
|
||||
for _, lang := range []i18n.Lang{"", "fr_fr", "ja_jp", "unknown"} {
|
||||
for _, lang := range []string{"", "fr", "ja", "unknown"} {
|
||||
msg := getLoginMsg(lang)
|
||||
if msg != loginMsgZh {
|
||||
t.Errorf("getLoginMsg(%q) should default to zh", lang)
|
||||
@@ -63,7 +61,7 @@ func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string)
|
||||
}
|
||||
|
||||
func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
msg := getLoginMsg(lang)
|
||||
|
||||
// LoginSuccess should contain two %s placeholders (userName, openId)
|
||||
@@ -99,17 +97,16 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous
|
||||
// auth-login output tells AI agents three things: (a) this command blocks for
|
||||
// minutes — set a long runner timeout, (b) the alternative is the --no-wait +
|
||||
// --device-code split-flow, and (c) non-streaming harnesses must end the turn
|
||||
// after presenting the URL instead of blocking in the same turn.
|
||||
// auth-login output tells AI agents two things: (a) this command blocks for
|
||||
// minutes — set a long runner timeout, and (b) the alternative is the
|
||||
// --no-wait + --device-code split-flow. Without (a) AI sets a 10s timeout and
|
||||
// kills the process before the user can authorize; without (b) the AI has no
|
||||
// recovery path and just retries with the same short timeout, invalidating
|
||||
// each new device code in turn.
|
||||
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
|
||||
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
hint := getLoginMsg(lang).AgentTimeoutHint
|
||||
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
|
||||
if lang == i18n.LangZhCN && want == "turn" {
|
||||
want = "本轮"
|
||||
}
|
||||
for _, want := range []string{"--no-wait", "--device-code"} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("%s AgentTimeoutHint missing %q: %s", lang, want, hint)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -172,12 +171,20 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope, "%s", issue.Message).
|
||||
WithHint("%s", issue.Hint).
|
||||
WithIdentity("user").
|
||||
WithRequestedScopes(issue.Summary.Requested...).
|
||||
WithGrantedScopes(issue.Summary.Granted...).
|
||||
WithMissingScopes(issue.Summary.Missing...)
|
||||
detail := map[string]interface{}{
|
||||
"requested": issue.Summary.Requested,
|
||||
"granted": issue.Summary.Granted,
|
||||
"missing": issue.Summary.Missing,
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "missing_scope",
|
||||
Message: issue.Message,
|
||||
Hint: issue.Hint,
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple asserts that the
|
||||
// failed-login JSON branch (loginSucceeded == false, opts.JSON == true) wires
|
||||
// requested + granted + missing scopes into the typed *PermissionError
|
||||
// envelope. Consumers need the full triple to render actionable diagnostics,
|
||||
// not just the missing set.
|
||||
func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
requested := []string{"docx:document", "im:message:send"}
|
||||
granted := []string{"docx:document"}
|
||||
missing := []string{"im:message:send"}
|
||||
|
||||
err := handleLoginScopeIssue(
|
||||
&LoginOptions{JSON: true},
|
||||
getLoginMsg("en"),
|
||||
f,
|
||||
&loginScopeIssue{
|
||||
Message: "scope insufficient",
|
||||
Hint: "re-login with --scope im:message:send",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: requested,
|
||||
Granted: granted,
|
||||
Missing: missing,
|
||||
},
|
||||
},
|
||||
"", // openId empty -> loginSucceeded = false
|
||||
"tester",
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if !reflect.DeepEqual(permErr.RequestedScopes, requested) {
|
||||
t.Errorf("RequestedScopes = %v, want %v", permErr.RequestedScopes, requested)
|
||||
}
|
||||
if !reflect.DeepEqual(permErr.GrantedScopes, granted) {
|
||||
t.Errorf("GrantedScopes = %v, want %v", permErr.GrantedScopes, granted)
|
||||
}
|
||||
if !reflect.DeepEqual(permErr.MissingScopes, missing) {
|
||||
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, missing)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -71,32 +70,6 @@ func TestSuggestDomain_ExactMatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeScopeInput(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"single", "vc:note:read", "vc:note:read"},
|
||||
{"comma", "vc:note:read,vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"space", "vc:note:read vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"comma_and_spaces", "vc:note:read, vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"mixed_separators", "a, b\tc\nd e", "a b c d e"},
|
||||
{"trim_and_dedup", " a , b , a ", "a b"},
|
||||
{"trailing_separators", "a,b,,", "a b"},
|
||||
{"only_separators", " , , ", ""},
|
||||
{"tab_separated", "im:message:send\toffline_access", "im:message:send offline_access"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := normalizeScopeInput(tc.in); got != tc.want {
|
||||
t.Errorf("normalizeScopeInput(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
|
||||
// Empty AuthTypes defaults to ["user"]
|
||||
sc := common.Shortcut{AuthTypes: nil}
|
||||
@@ -172,7 +145,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllKnownDomains(t *testing.T) {
|
||||
domains := allKnownDomains("")
|
||||
domains := allKnownDomains()
|
||||
if len(domains) == 0 {
|
||||
t.Fatal("expected non-empty known domains")
|
||||
}
|
||||
@@ -186,7 +159,7 @@ func TestAllKnownDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSortedKnownDomains(t *testing.T) {
|
||||
sorted := sortedKnownDomains("")
|
||||
sorted := sortedKnownDomains()
|
||||
if len(sorted) == 0 {
|
||||
t.Fatal("expected non-empty sorted domains")
|
||||
}
|
||||
@@ -196,7 +169,7 @@ func TestSortedKnownDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should match allKnownDomains
|
||||
known := allKnownDomains("")
|
||||
known := allKnownDomains()
|
||||
if len(sorted) != len(known) {
|
||||
t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known))
|
||||
}
|
||||
@@ -215,19 +188,13 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
|
||||
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
|
||||
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains(t *testing.T) {
|
||||
projects := registry.ListFromMetaProjects()
|
||||
if len(projects) == 0 {
|
||||
t.Skip("no from_meta data available")
|
||||
}
|
||||
|
||||
scopes := collectScopesForDomains([]string{"calendar"}, "user", "")
|
||||
scopes := collectScopesForDomains([]string{"calendar"}, "user")
|
||||
if len(scopes) == 0 {
|
||||
t.Fatal("expected non-empty scopes for calendar domain")
|
||||
}
|
||||
@@ -254,7 +221,7 @@ func TestCollectScopesForDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user", "")
|
||||
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user")
|
||||
if len(scopes) != 0 {
|
||||
t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes))
|
||||
}
|
||||
@@ -322,12 +289,10 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
||||
if !strings.Contains(msg, "scopes") {
|
||||
t.Errorf("expected error to mention scopes, got: %s", msg)
|
||||
}
|
||||
// Stderr should explain the split-flow path for non-streaming agents.
|
||||
// Stderr should contain background hint
|
||||
stderrStr := stderr.String()
|
||||
for _, want := range []string{"--no-wait --json", "final message of the turn", "--device-code"} {
|
||||
if !strings.Contains(stderrStr, want) {
|
||||
t.Errorf("expected stderr to mention %q, got: %s", want, stderrStr)
|
||||
}
|
||||
if !strings.Contains(stderrStr, "background") {
|
||||
t.Errorf("expected stderr to mention background, got: %s", stderrStr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,11 +372,12 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
@@ -449,11 +415,12 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
@@ -658,11 +625,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
@@ -874,87 +842,6 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty pins the
|
||||
// contract that when --json is set and pollDeviceToken returns OK=false,
|
||||
// stdout carries the structured authorization_failed event and stderr is
|
||||
// NOT polluted with a typed envelope. The returned error is a bare
|
||||
// BareError with ExitAuth so the dispatcher only propagates the exit code
|
||||
// without emitting a second envelope on top of the JSON event.
|
||||
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
original := pollDeviceToken
|
||||
t.Cleanup(func() { pollDeviceToken = original })
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
|
||||
}
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 0,
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
JSON: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for aborted authorization")
|
||||
}
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||
}
|
||||
|
||||
// stdout: device_authorization event + authorization_failed event,
|
||||
// the latter carrying the abort message as a structured field.
|
||||
stdoutStr := stdout.String()
|
||||
if !strings.Contains(stdoutStr, `"event":"authorization_failed"`) {
|
||||
t.Errorf("stdout missing authorization_failed event, got: %s", stdoutStr)
|
||||
}
|
||||
if !strings.Contains(stdoutStr, "user denied") {
|
||||
t.Errorf("stdout missing abort message, got: %s", stdoutStr)
|
||||
}
|
||||
|
||||
// stderr must NOT carry a typed envelope: ErrBare propagates the exit
|
||||
// code only, so the dispatcher emits nothing on stderr. The waiting-auth
|
||||
// log line goes through the JSON-mode no-op `log` helper so it is also
|
||||
// suppressed in JSON mode.
|
||||
stderrStr := stderr.String()
|
||||
if strings.Contains(stderrStr, `"type":"authentication"`) {
|
||||
t.Errorf("stderr should not contain typed envelope, got: %s", stderrStr)
|
||||
}
|
||||
if strings.Contains(stderrStr, `"error"`) {
|
||||
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
|
||||
}
|
||||
|
||||
// Returned error must be the bare *output.BareError signal (no envelope).
|
||||
var bareErr *output.BareError
|
||||
if !errors.As(err, &bareErr) {
|
||||
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
|
||||
}
|
||||
if bareErr.Code != output.ExitAuth {
|
||||
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
@@ -992,80 +879,6 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 5,
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
NoWait: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authLoginRun() error = %v", err)
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(strings.NewReader(stdout.String()))
|
||||
var data map[string]interface{}
|
||||
if err := dec.Decode(&data); err != nil {
|
||||
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
|
||||
}
|
||||
hint, _ := data["hint"].(string)
|
||||
for _, want := range []string{
|
||||
"MUST generate QR code AND display it",
|
||||
"lark-cli auth qrcode",
|
||||
"Prefer PNG QR code (--output)",
|
||||
"use ASCII (--ascii) only when the user explicitly requests it",
|
||||
"This is a required step, do NOT skip it",
|
||||
"CRITICAL",
|
||||
"You MUST include the QR image in your response",
|
||||
"Generating the file alone is NOT enough",
|
||||
"image tags, inline images, or file attachments",
|
||||
"Display order",
|
||||
"place the QR code image below the URL",
|
||||
"opaque string",
|
||||
"cannot be modified",
|
||||
"final message of the turn",
|
||||
"return control to the user",
|
||||
"do not block on --device-code in the same turn",
|
||||
"come back and notify",
|
||||
"YOU must execute",
|
||||
"lark-cli auth login --device-code <device_code>",
|
||||
"Do NOT cache",
|
||||
"lark-cli auth login --no-wait --json",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"Then immediately execute",
|
||||
"Do not instruct the user to run this command themselves",
|
||||
} {
|
||||
if strings.Contains(hint, unwanted) {
|
||||
t.Fatalf("hint should not contain %q, got:\n%s", unwanted, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
@@ -1104,69 +917,6 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 5,
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: ctx,
|
||||
Scope: "im:message:send",
|
||||
JSON: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from cancelled context")
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(strings.NewReader(stdout.String()))
|
||||
var data map[string]interface{}
|
||||
if err := dec.Decode(&data); err != nil {
|
||||
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
|
||||
}
|
||||
hint, _ := data["agent_hint"].(string)
|
||||
for _, want := range []string{
|
||||
"timeout >= 600s",
|
||||
"本轮最终消息",
|
||||
"结束本轮",
|
||||
"用户回复已完成授权",
|
||||
"不要在同一轮里展示 URL 后立刻阻塞执行 --device-code",
|
||||
"必须生成二维码并展示",
|
||||
"lark-cli auth qrcode",
|
||||
"优先生成 PNG 二维码(--output)",
|
||||
"仅当用户明确要求时才使用 ASCII(--ascii)",
|
||||
"生成后必须在回复中展示图片",
|
||||
"仅生成文件不算完成",
|
||||
"image 标签或内联图片",
|
||||
"二维码图片置于 URL 下方完整展示",
|
||||
"URL 输出规则",
|
||||
"opaque string",
|
||||
"不要做任何修改",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
@@ -1177,7 +927,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := allKnownDomains("")
|
||||
domains := allKnownDomains()
|
||||
if domains["whiteboard"] {
|
||||
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
|
||||
}
|
||||
@@ -1187,7 +937,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"docs"}, "user", "")
|
||||
scopes := collectScopesForDomains([]string{"docs"}, "user")
|
||||
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
|
||||
found := false
|
||||
for _, s := range scopes {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
// LogoutOptions holds all inputs for auth logout.
|
||||
type LogoutOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthLogout creates the auth logout subcommand.
|
||||
@@ -35,8 +33,6 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
return authLogoutRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -46,64 +42,24 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
httpClient, httpErr := f.HttpClient()
|
||||
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
|
||||
|
||||
for _, user := range app.Users {
|
||||
if httpErr == nil && secretErr == nil {
|
||||
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
|
||||
revokeToken := token.RefreshToken
|
||||
tokenTypeHint := "refresh_token"
|
||||
if revokeToken == "" {
|
||||
revokeToken = token.AccessToken
|
||||
tokenTypeHint = "access_token"
|
||||
}
|
||||
if revokeToken != "" {
|
||||
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
|
||||
}
|
||||
}
|
||||
|
||||
app.Users = []core.AppUser{}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": true,
|
||||
})
|
||||
return nil
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||
return nil
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
|
||||
t.Helper()
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "test-app",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: users,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != true {
|
||||
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
|
||||
}
|
||||
if _, hasReason := payload["reason"]; hasReason {
|
||||
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Logged out") {
|
||||
t.Errorf("stderr = %q, want success text", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "user-refresh-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_test" &&
|
||||
values.Get("client_secret") == "secret" &&
|
||||
values.Get("token") == "user-refresh-token" &&
|
||||
values.Get("token_type_hint") == "refresh_token"
|
||||
},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", got)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_test" &&
|
||||
values.Get("client_secret") == "secret" &&
|
||||
values.Get("token") == "user-access-token" &&
|
||||
values.Get("token_type_hint") == "access_token"
|
||||
},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", got)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "user-refresh-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"error": "server_error"},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
gotErr := stderr.String()
|
||||
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
|
||||
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
|
||||
}
|
||||
if !strings.Contains(gotErr, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", gotErr)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// QRCodeOptions holds inputs for auth qrcode command.
|
||||
type QRCodeOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
URL string
|
||||
Size int
|
||||
ASCII bool
|
||||
Output string
|
||||
}
|
||||
|
||||
// NewCmdAuthQRCode creates the auth qrcode subcommand.
|
||||
func NewCmdAuthQRCode(f *cmdutil.Factory, runF func(*QRCodeOptions) error) *cobra.Command {
|
||||
opts := &QRCodeOptions{Factory: f, Size: 256}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "qrcode <url>",
|
||||
Short: "Generate QR code for verification URL",
|
||||
Long: `Generate a QR code image or ASCII representation for a verification URL.
|
||||
|
||||
This command is designed for AI agents to generate QR codes for OAuth authorization URLs.
|
||||
|
||||
For PNG output, the --output flag is required to specify the output file path (must be a relative path within the current directory).
|
||||
For ASCII output, the result is printed to stdout with fixed size.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.URL = args[0]
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runQRCode(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
|
||||
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runQRCode executes the auth qrcode command.
|
||||
func runQRCode(opts *QRCodeOptions) error {
|
||||
if opts.URL == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "url is required").WithParam("--url")
|
||||
}
|
||||
|
||||
if opts.ASCII {
|
||||
var out io.Writer = os.Stdout
|
||||
if opts.Factory != nil {
|
||||
out = opts.Factory.IOStreams.Out
|
||||
}
|
||||
return generateASCIIQRCode(opts.URL, out)
|
||||
}
|
||||
|
||||
if opts.Output == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.").WithParam("--output")
|
||||
}
|
||||
|
||||
if opts.Size < 32 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at least 32, got %d", opts.Size).WithParam("--size")
|
||||
}
|
||||
|
||||
if opts.Size > 1024 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at most 1024, got %d", opts.Size).WithParam("--size")
|
||||
}
|
||||
|
||||
safePath, err := validate.SafeOutputPath(opts.Output)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"ok": true,
|
||||
"file_path": safePath,
|
||||
"hint": "You MUST include the QR image in your response. Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.",
|
||||
}
|
||||
|
||||
var out io.Writer = os.Stdout
|
||||
if opts.Factory != nil {
|
||||
out = opts.Factory.IOStreams.Out
|
||||
}
|
||||
encoder := json.NewEncoder(out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(result); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write output: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateImageQRCode encodes the URL as a PNG QR code and writes it to outputPath.
|
||||
func generateImageQRCode(url string, size int, outputPath string) error {
|
||||
png, err := qrcode.Encode(url, qrcode.Medium, size)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to encode QR code: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
err = vfs.WriteFile(outputPath, png, 0644)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write QR code to %s: %v", outputPath, err).WithCause(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateASCIIQRCode encodes the URL as an ASCII QR code and prints it to stdout.
|
||||
func generateASCIIQRCode(url string, w io.Writer) error {
|
||||
q, err := qrcode.New(url, qrcode.Medium)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to create QR code: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
fmt.Fprint(w, q.ToSmallString(false))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png", "--size", "128"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.URL != "https://example.com" {
|
||||
t.Errorf("URL = %q, want %q", gotOpts.URL, "https://example.com")
|
||||
}
|
||||
if gotOpts.Size != 128 {
|
||||
t.Errorf("Size = %d, want %d", gotOpts.Size, 128)
|
||||
}
|
||||
if gotOpts.Output != "qr.png" {
|
||||
t.Errorf("Output = %q, want %q", gotOpts.Output, "qr.png")
|
||||
}
|
||||
if gotOpts.ASCII {
|
||||
t.Error("ASCII should be false by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"https://example.com", "--ascii"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !gotOpts.ASCII {
|
||||
t.Error("ASCII should be true when --ascii is passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_DefaultSize(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"https://example.com", "--ascii"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.Size != 256 {
|
||||
t.Errorf("default Size = %d, want 256", gotOpts.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_ExactOneArg(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error when no URL argument provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_RunE_PNGEndToEnd(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
tmpDir := t.TempDir()
|
||||
oldWd, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(oldWd) })
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("qr.png")
|
||||
if err != nil {
|
||||
t.Fatalf("output file not created: %v", err)
|
||||
}
|
||||
if string(data[:4]) != "\x89PNG" {
|
||||
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v, got: %s", err, stdout.String())
|
||||
}
|
||||
if result["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", result["ok"])
|
||||
}
|
||||
hint, _ := result["hint"].(string)
|
||||
if hint == "" {
|
||||
t.Error("hint is empty")
|
||||
}
|
||||
if !strings.Contains(hint, "MUST include") {
|
||||
t.Errorf("hint missing 'MUST include', got: %s", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "NOT enough") {
|
||||
t.Errorf("hint missing 'NOT enough', got: %s", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_RunE_MissingOutput(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"https://example.com"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error when --output is missing in PNG mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"qrcode <url>",
|
||||
"QR code",
|
||||
"--output",
|
||||
"--ascii",
|
||||
"relative path",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("help missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_MissingURL(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{URL: ""})
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_MissingOutput(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_InvalidSize(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 16,
|
||||
Output: "qr.png",
|
||||
})
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_SizeTooLarge(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 2048,
|
||||
Output: "qr.png",
|
||||
})
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 256,
|
||||
Output: "/etc/passwd",
|
||||
})
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_PNGWritesFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
tmpDir := t.TempDir()
|
||||
oldWd, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(oldWd) })
|
||||
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 256,
|
||||
Output: "qr.png",
|
||||
Factory: f,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat("qr.png")
|
||||
if err != nil {
|
||||
t.Fatalf("output file not created: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Error("output file is empty")
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if jsonErr := json.Unmarshal(stdout.Bytes(), &result); jsonErr != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v, got: %s", jsonErr, stdout.String())
|
||||
}
|
||||
if result["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", result["ok"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_ASCIIOutputsToStdout(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
ASCII: true,
|
||||
Factory: f,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() == 0 {
|
||||
t.Error("ASCII QR code produced no output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageQRCode_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "test-qr.png")
|
||||
|
||||
if err := generateImageQRCode("https://example.com", 256, outputPath); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output file: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("output file is empty")
|
||||
}
|
||||
if len(data) < 8 {
|
||||
t.Error("output too small to be a valid PNG")
|
||||
}
|
||||
if string(data[:4]) != "\x89PNG" {
|
||||
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageQRCode_WriteError(t *testing.T) {
|
||||
err := generateImageQRCode("https://example.com", 256, "/nonexistent/deep/nested/dir/qr.png")
|
||||
if err == nil {
|
||||
t.Fatal("expected error writing to nonexistent directory")
|
||||
}
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateASCIIQRCode_Success(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
err := generateASCIIQRCode("https://example.com", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Error("ASCII QR code produced no output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
err := generateASCIIQRCode("", &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty string")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -19,7 +18,6 @@ type ScopesOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
Format string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthScopes creates the auth scopes subcommand.
|
||||
@@ -31,9 +29,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
Short: "Query scopes enabled for the app",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
if opts.JSON {
|
||||
opts.Format = "json"
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -42,8 +37,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -56,23 +49,11 @@ func authScopesRun(opts *ScopesOptions) error {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n")
|
||||
appInfo, err := getAppInfoFn(opts.Ctx, f, config.AppID)
|
||||
appInfo, err := getAppInfo(opts.Ctx, f, config.AppID)
|
||||
if err != nil {
|
||||
// Discriminate by error type so transport / parse failures are not
|
||||
// reclassified as PermissionError(MissingScope) — re-auth does not
|
||||
// fix network / 5xx / JSON parse errors and misclassifying them
|
||||
// here would mislead agents into re-auth loops.
|
||||
// - typed errors pass through unchanged
|
||||
// - bare errors become InternalError(SubtypeSDKError) with Cause
|
||||
// preserved so callers (errors.Is) can still see the underlying
|
||||
// transport/parse failure.
|
||||
// Genuine permission failures are surfaced from appInfo *content*,
|
||||
// not from this transport-level error path.
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError,
|
||||
"failed to get app scope info: %v", err).WithCause(err)
|
||||
return output.ErrWithHint(output.ExitAPI, "permission",
|
||||
fmt.Sprintf("failed to get app scope info: %v", err),
|
||||
"ensure the app has enabled the application:application:self_manage scope.")
|
||||
}
|
||||
if opts.Format == "pretty" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// stubGetAppInfoErr swaps getAppInfoFn for the duration of t so authScopesRun
|
||||
// observes a fixed error from the dependency. t.Cleanup restores the prior
|
||||
// value so tests cannot leak through the package-level seam.
|
||||
func stubGetAppInfoErr(t *testing.T, errToReturn error) {
|
||||
t.Helper()
|
||||
prev := getAppInfoFn
|
||||
getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||
return nil, errToReturn
|
||||
}
|
||||
t.Cleanup(func() { getAppInfoFn = prev })
|
||||
}
|
||||
|
||||
// scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive
|
||||
// authScopesRun. Config has a non-empty AppID so we get past the config gate
|
||||
// and reach the getAppInfoFn call.
|
||||
func scopesTestFactory(t *testing.T) *ScopesOptions {
|
||||
t.Helper()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
return &ScopesOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Format: "json",
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthScopesRun_NetworkErrorPassedThrough pins that a typed NetworkError
|
||||
// surfaced by the dependency is not re-classified as PermissionError —
|
||||
// re-auth does not fix DNS / transport failures and blanket-wrapping them
|
||||
// would mislead agents into infinite re-auth loops.
|
||||
func TestAuthScopesRun_NetworkErrorPassedThrough(t *testing.T) {
|
||||
netErr := errs.NewNetworkError(errs.SubtypeNetworkDNS, "DNS lookup failed")
|
||||
stubGetAppInfoErr(t, netErr)
|
||||
|
||||
err := authScopesRun(scopesTestFactory(t))
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var permErr *errs.PermissionError
|
||||
if errors.As(err, &permErr) {
|
||||
t.Errorf("network failure must not be classified as PermissionError; got %v", permErr)
|
||||
}
|
||||
var gotNet *errs.NetworkError
|
||||
if !errors.As(err, &gotNet) {
|
||||
t.Fatalf("network failure not preserved through authScopesRun; got %T: %v", err, err)
|
||||
}
|
||||
if gotNet != netErr {
|
||||
t.Errorf("typed network error should pass through identity-stable; got %p, want %p", gotNet, netErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthScopesRun_PermissionErrorPassedThrough pins that typed permission
|
||||
// failures from the dependency also pass through — IsTyped() must not single
|
||||
// out one category.
|
||||
func TestAuthScopesRun_PermissionErrorPassedThrough(t *testing.T) {
|
||||
permErr := errs.NewPermissionError(errs.SubtypeMissingScope, "scope X missing").
|
||||
WithMissingScopes("im:message")
|
||||
stubGetAppInfoErr(t, permErr)
|
||||
|
||||
err := authScopesRun(scopesTestFactory(t))
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var got *errs.PermissionError
|
||||
if !errors.As(err, &got) {
|
||||
t.Fatalf("expected *PermissionError pass-through, got %T: %v", err, err)
|
||||
}
|
||||
if got != permErr {
|
||||
t.Errorf("typed permission error should pass through identity-stable; got %p, want %p", got, permErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthScopesRun_BareErrorWrappedAsInternal pins the unclassified branch:
|
||||
// a bare error (e.g. json.Unmarshal failure inside getAppInfo) surfaces as
|
||||
// *InternalError{SubtypeSDKError} with the original error preserved on
|
||||
// Cause so errors.Is still walks to it.
|
||||
func TestAuthScopesRun_BareErrorWrappedAsInternal(t *testing.T) {
|
||||
bareErr := fmt.Errorf("failed to parse response: unexpected EOF")
|
||||
stubGetAppInfoErr(t, bareErr)
|
||||
|
||||
err := authScopesRun(scopesTestFactory(t))
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var permErr *errs.PermissionError
|
||||
if errors.As(err, &permErr) {
|
||||
t.Errorf("bare getAppInfo error must not be classified as PermissionError; got %v", permErr)
|
||||
}
|
||||
|
||||
var intErr *errs.InternalError
|
||||
if !errors.As(err, &intErr) {
|
||||
t.Fatalf("expected *InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if intErr.Subtype != errs.SubtypeSDKError {
|
||||
t.Errorf("InternalError.Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError)
|
||||
}
|
||||
if !errors.Is(err, bareErr) {
|
||||
t.Error("InternalError must carry bareErr via WithCause so errors.Is walks to it")
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -17,7 +19,6 @@ import (
|
||||
type StatusOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Verify bool
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthStatus creates the auth status subcommand.
|
||||
@@ -36,8 +37,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -60,59 +59,73 @@ func authStatusRun(opts *StatusOptions) error {
|
||||
"defaultAs": defaultAs,
|
||||
}
|
||||
|
||||
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
addStatusNote(result, diagnostics)
|
||||
if config.UserOpenId == "" {
|
||||
result["identity"] = "bot"
|
||||
result["note"] = "No user logged in. Only bot (tenant) identity is available for API calls. Run `lark-cli auth login` to log in."
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
result["identity"] = "bot"
|
||||
result["userName"] = config.UserName
|
||||
result["userOpenId"] = config.UserOpenId
|
||||
result["note"] = "Token does not exist or has been cleared. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
status := larkauth.TokenStatus(stored)
|
||||
if status == "expired" {
|
||||
result["identity"] = "bot"
|
||||
result["note"] = "User token has expired. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
|
||||
} else {
|
||||
result["identity"] = "user"
|
||||
}
|
||||
result["userName"] = config.UserName
|
||||
result["userOpenId"] = config.UserOpenId
|
||||
result["tokenStatus"] = status
|
||||
result["scope"] = stored.Scope
|
||||
result["expiresAt"] = time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)
|
||||
result["refreshExpiresAt"] = time.UnixMilli(stored.RefreshExpiresAt).Format(time.RFC3339)
|
||||
result["grantedAt"] = time.UnixMilli(stored.GrantedAt).Format(time.RFC3339)
|
||||
|
||||
// --verify: call the server to confirm token is actually usable.
|
||||
if opts.Verify && status != "expired" {
|
||||
verified, verifyErr := verifyTokenOnServer(f, config)
|
||||
result["verified"] = verified
|
||||
if verifyErr != "" {
|
||||
result["verifyError"] = verifyErr
|
||||
}
|
||||
}
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
identityUser = "user"
|
||||
identityBot = "bot"
|
||||
identityNone = "none"
|
||||
)
|
||||
|
||||
func effectiveIdentity(d identitydiag.Result) string {
|
||||
switch {
|
||||
case d.User.Available:
|
||||
return identityUser
|
||||
case d.Bot.Available:
|
||||
return identityBot
|
||||
default:
|
||||
return identityNone
|
||||
// verifyTokenOnServer obtains a valid access token (refreshing if needed)
|
||||
// and calls /authen/v1/user_info to confirm the server accepts it.
|
||||
// Returns (true, "") on success or (false, reason) on failure.
|
||||
func verifyTokenOnServer(f *cmdutil.Factory, config *core.CliConfig) (bool, string) {
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return false, "failed to create HTTP client: " + err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch result["identity"] {
|
||||
case identityUser:
|
||||
if d.User.Verified != nil {
|
||||
result["verified"] = *d.User.Verified
|
||||
if !*d.User.Verified {
|
||||
result["verifyError"] = d.User.Message
|
||||
}
|
||||
}
|
||||
case identityBot:
|
||||
if d.Bot.Verified != nil {
|
||||
result["verified"] = *d.Bot.Verified
|
||||
if !*d.Bot.Verified {
|
||||
result["verifyError"] = d.Bot.Message
|
||||
}
|
||||
}
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(config, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
return false, "token unusable: " + err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
|
||||
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."
|
||||
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`."
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
return false, "failed to create SDK client: " + err.Error()
|
||||
}
|
||||
|
||||
if err := larkauth.VerifyUserToken(context.Background(), sdk, token); err != nil {
|
||||
return false, "server rejected token: " + err.Error()
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got statusOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if got.Identities.Bot.Status != "ready" || !got.Identities.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want ready and available", got.Identities.Bot)
|
||||
}
|
||||
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot",
|
||||
"app_name": "diagnostic bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got statusOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if got.Verified == nil || !*got.Verified {
|
||||
t.Fatalf("verified = %v, want true", got.Verified)
|
||||
}
|
||||
if got.Identities.Bot.Verified == nil || !*got.Identities.Bot.Verified {
|
||||
t.Fatalf("bot verified = %v, want true", got.Identities.Bot.Verified)
|
||||
}
|
||||
if got.Identities.Bot.OpenID != "ou_bot" {
|
||||
t.Fatalf("bot open id = %q, want ou_bot", got.Identities.Bot.OpenID)
|
||||
}
|
||||
if got.Identities.User.Status != "missing" {
|
||||
t.Fatalf("user status = %q, want missing", got.Identities.User.Status)
|
||||
}
|
||||
}
|
||||
|
||||
type statusOutput struct {
|
||||
Identity string `json:"identity"`
|
||||
Verified *bool `json:"verified"`
|
||||
Identities struct {
|
||||
Bot statusIdentity `json:"bot"`
|
||||
User statusIdentity `json:"user"`
|
||||
} `json:"identities"`
|
||||
}
|
||||
|
||||
type statusIdentity struct {
|
||||
Status string `json:"status"`
|
||||
Available bool `json:"available"`
|
||||
Verified *bool `json:"verified"`
|
||||
OpenID string `json:"openId"`
|
||||
}
|
||||
141
cmd/build.go
141
cmd/build.go
@@ -6,7 +6,6 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
@@ -17,14 +16,10 @@ import (
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -34,13 +29,9 @@ 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
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
globals GlobalOptions
|
||||
}
|
||||
|
||||
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
||||
@@ -58,18 +49,6 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
||||
// at build time. It is registered by the repo-root package main's init via
|
||||
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
||||
// breaking the single-file preview build (see skills_embed.go). nil in builds
|
||||
// that embed no skills; the `skills` commands then return a typed internal error.
|
||||
var embeddedSkillContent fs.FS
|
||||
|
||||
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
|
||||
// repo-root package main's init; a wrapper main can call it before Execute to
|
||||
// supply its own skill content.
|
||||
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = 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
|
||||
@@ -80,63 +59,18 @@ func HideProfile(hide bool) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithoutPlugins builds only repository-owned commands. It is intended for
|
||||
// inspection tools that need a deterministic command tree.
|
||||
func WithoutPlugins() BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.skipPlugins = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithoutStrictMode builds the complete repository-owned command tree without
|
||||
// applying user/profile strict-mode pruning. It is intended for offline
|
||||
// inspection tools, not production execution.
|
||||
func WithoutStrictMode() BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.skipStrictMode = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithoutServiceCommands builds only hand-authored commands. It is intended for
|
||||
// repository quality gates that should not depend on the remote OpenAPI
|
||||
// metadata command surface.
|
||||
func WithoutServiceCommands() BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.skipService = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithServiceCatalog builds generated service commands from a specific metadata
|
||||
// catalog. It is intended for offline inspection tools that need deterministic
|
||||
// embedded metadata while production execution keeps using the runtime catalog.
|
||||
func WithServiceCatalog(catalog apicatalog.Catalog) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.serviceCatalog = &catalog
|
||||
}
|
||||
}
|
||||
|
||||
// Build constructs the full command tree. It also installs registered
|
||||
// plugins and emits the Startup lifecycle event during assembly --
|
||||
// so Plugin.On(Startup) handlers run even if the returned command is
|
||||
// never dispatched. The matching Shutdown event is only emitted by
|
||||
// Execute; callers that bypass Execute will not see Shutdown fire.
|
||||
//
|
||||
// Returns only the cobra.Command; Factory and hook Registry are internal.
|
||||
// Build constructs the full command tree without executing.
|
||||
// Returns only the cobra.Command; Factory is internal.
|
||||
// Use Execute for the standard production entry point.
|
||||
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
|
||||
_, rootCmd, _ := buildInternal(ctx, inv, opts...)
|
||||
_, rootCmd := buildInternal(ctx, inv, opts...)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// buildInternal is a pure assembly function: it wires the command tree from
|
||||
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
|
||||
// env) belongs in the caller and must be threaded in via BuildOption.
|
||||
//
|
||||
// Returns (factory, 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) (*cmdutil.Factory, *cobra.Command) {
|
||||
// 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{}
|
||||
@@ -157,7 +91,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
f.SkillContent = embeddedSkillContent
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
@@ -172,18 +105,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
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
|
||||
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
|
||||
// inherited by every subcommand, turning unknown-flag errors into a
|
||||
// structured "did you mean" envelope.
|
||||
rootCmd.SilenceUsage = true
|
||||
rootCmd.SetFlagErrorFunc(flagDidYouMean)
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
f.CurrentCommand = cmd
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
@@ -195,57 +120,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||
if !cfg.skipService {
|
||||
if cfg.serviceCatalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
|
||||
} else {
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
}
|
||||
}
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
// Prune commands incompatible with strict mode.
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
if cfg.skipPlugins {
|
||||
recordInventory(nil)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
warnPolicyError(cfg.streams.ErrOut, err)
|
||||
}
|
||||
|
||||
if registry != nil {
|
||||
if err := wireHooks(ctx, rootCmd, registry); err != nil {
|
||||
installPluginLifecycleErrorGuard(rootCmd, err)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
}
|
||||
|
||||
recordInventory(installResult)
|
||||
return f, rootCmd, registry
|
||||
return f, rootCmd
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
|
||||
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
||||
|
||||
if root == nil {
|
||||
t.Fatal("Build returned nil root")
|
||||
}
|
||||
if findCommand(root, "api") == nil {
|
||||
t.Fatal("builtin api command missing")
|
||||
}
|
||||
if findCommand(root, "docs +fetch") == nil {
|
||||
t.Fatal("builtin docs +fetch shortcut missing")
|
||||
}
|
||||
}
|
||||
|
||||
func findCommand(root *cobra.Command, path string) *cobra.Command {
|
||||
parts := strings.Fields(path)
|
||||
cmd := root
|
||||
for _, part := range parts {
|
||||
var next *cobra.Command
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Name() == part {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
cmd = next
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// universalFlags are accepted by every command (cobra auto-injects help; the
|
||||
// root injects version). They are never reported as unknown.
|
||||
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
|
||||
|
||||
// catalog is the source-of-truth command catalog: command path -> accepted flag
|
||||
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
|
||||
// "contact +search-user". The root command is the empty path "".
|
||||
type catalog struct {
|
||||
flagsByPath map[string]map[string]bool
|
||||
group map[string]bool // paths that are parent groups (have subcommands)
|
||||
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
|
||||
}
|
||||
|
||||
func newCatalog() *catalog {
|
||||
return &catalog{
|
||||
flagsByPath: map[string]map[string]bool{},
|
||||
group: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// setGroup records whether path is a parent group (has subcommands). Leftover
|
||||
// words after a group node are unknown subcommands; after a leaf they are
|
||||
// positionals (e.g. "api GET /path").
|
||||
func (c *catalog) setGroup(path string, isGroup bool) {
|
||||
if isGroup {
|
||||
c.group[path] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (c *catalog) isGroup(path string) bool { return c.group[path] }
|
||||
|
||||
// addCommand registers a command path and the flags it accepts. Repeated calls
|
||||
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
|
||||
func (c *catalog) addCommand(path string, flags []string) {
|
||||
set := c.flagsByPath[path]
|
||||
if set == nil {
|
||||
set = map[string]bool{}
|
||||
c.flagsByPath[path] = set
|
||||
}
|
||||
for _, f := range flags {
|
||||
set[f] = true
|
||||
}
|
||||
c.sorted = nil // invalidate cached suggestion list
|
||||
}
|
||||
|
||||
func (c *catalog) hasCommand(path string) bool {
|
||||
_, ok := c.flagsByPath[path]
|
||||
return ok
|
||||
}
|
||||
|
||||
// hasFlag reports whether flag is accepted by command path (universal flags
|
||||
// always pass).
|
||||
func (c *catalog) hasFlag(path, flag string) bool {
|
||||
if universalFlags[flag] {
|
||||
return true
|
||||
}
|
||||
set := c.flagsByPath[path]
|
||||
return set[flag]
|
||||
}
|
||||
|
||||
// longestPrefix returns the longest known command path that is a prefix of
|
||||
// words, plus how many words it consumed. This separates real subcommands from
|
||||
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
|
||||
// empty it falls back to the root command. ok=false means not even the first
|
||||
// word names a command.
|
||||
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
|
||||
if len(words) == 0 {
|
||||
if c.hasCommand("") {
|
||||
return "", 0, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
for i := len(words); i >= 1; i-- {
|
||||
cand := strings.Join(words[:i], " ")
|
||||
if c.hasCommand(cand) {
|
||||
return cand, i, true
|
||||
}
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// paths returns all known command paths, sorted.
|
||||
func (c *catalog) paths() []string {
|
||||
out := make([]string, 0, len(c.flagsByPath))
|
||||
for p := range c.flagsByPath {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// suggestCommand returns the known command path closest to want (small edit
|
||||
// distance), for error hints. Returns "" when nothing is reasonably close.
|
||||
func (c *catalog) suggestCommand(want string) string {
|
||||
if c.sorted == nil {
|
||||
c.sorted = c.paths() // built once after the catalog is fully populated
|
||||
}
|
||||
return closest(want, c.sorted)
|
||||
}
|
||||
|
||||
// suggestFlag returns the flag of path closest to flag, for error hints.
|
||||
func (c *catalog) suggestFlag(path, flag string) string {
|
||||
set := c.flagsByPath[path]
|
||||
cands := make([]string, 0, len(set))
|
||||
for f := range set {
|
||||
cands = append(cands, f)
|
||||
}
|
||||
sort.Strings(cands)
|
||||
return closest(flag, cands)
|
||||
}
|
||||
|
||||
// closest returns the candidate with the smallest Levenshtein distance to want,
|
||||
// but only if that distance is within a tolerance scaled to want's length
|
||||
// (avoids absurd suggestions).
|
||||
func closest(want string, cands []string) string {
|
||||
best := ""
|
||||
bestD := 1 << 30
|
||||
for _, cand := range cands {
|
||||
d := levenshtein(want, cand)
|
||||
if d < bestD {
|
||||
bestD, best = d, cand
|
||||
}
|
||||
}
|
||||
tol := len(want)/2 + 1
|
||||
if bestD > tol {
|
||||
return ""
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func levenshtein(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
prev := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
cur := make([]int, len(rb)+1)
|
||||
cur[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev = cur
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd_test
|
||||
|
||||
import "strings"
|
||||
|
||||
// Finding kinds.
|
||||
const (
|
||||
unknownCommand = "unknown_command"
|
||||
unknownFlag = "unknown_flag"
|
||||
)
|
||||
|
||||
// finding is a single mismatch between an example command reference and the
|
||||
// catalog.
|
||||
type finding struct {
|
||||
line int
|
||||
raw string
|
||||
kind string // unknownCommand | unknownFlag
|
||||
path string // resolved command path (unknownFlag) or attempted path (unknownCommand)
|
||||
flag string // offending flag (unknownFlag only)
|
||||
suggest string // nearest known command/flag, "" if none close
|
||||
}
|
||||
|
||||
// checkRefs validates refs against cat and returns all mismatches in order.
|
||||
func checkRefs(cat *catalog, refs []ref) []finding {
|
||||
var out []finding
|
||||
for _, r := range refs {
|
||||
path, n, ok := cat.longestPrefix(r.words)
|
||||
if !ok {
|
||||
attempted := strings.Join(r.words, " ")
|
||||
out = append(out, finding{
|
||||
line: r.line, raw: r.raw, kind: unknownCommand,
|
||||
path: attempted, suggest: cat.suggestCommand(attempted),
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Leftover words after a group node are an unknown subcommand (e.g. a
|
||||
// mistyped method like "batch_modify_message"). After a leaf they are
|
||||
// positionals (e.g. "api GET /path"), so only groups trigger this.
|
||||
if n < len(r.words) && cat.isGroup(path) {
|
||||
attempted := strings.Join(r.words, " ")
|
||||
out = append(out, finding{
|
||||
line: r.line, raw: r.raw, kind: unknownCommand,
|
||||
path: attempted, suggest: cat.suggestCommand(attempted),
|
||||
})
|
||||
continue
|
||||
}
|
||||
for _, f := range r.flags {
|
||||
if cat.hasFlag(path, f) {
|
||||
continue
|
||||
}
|
||||
out = append(out, finding{
|
||||
line: r.line, raw: r.raw, kind: unknownFlag,
|
||||
path: path, flag: f, suggest: cat.suggestFlag(path, f),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ref is one lark-cli command reference extracted from a shortcut example.
|
||||
type ref struct {
|
||||
line int // 1-based line number (the line where the command starts)
|
||||
raw string // reconstructed command text, for error display
|
||||
words []string // command words before the first flag (subcommand candidates)
|
||||
flags []string // flag tokens used, e.g. "--query", "-q"
|
||||
}
|
||||
|
||||
const cliToken = "lark-cli"
|
||||
|
||||
// subcommandStart guards against false positives from prose: a real command's
|
||||
// first word is ASCII (a service name or a +shortcut). A token starting with
|
||||
// CJK / punctuation is treated as narration, not a command.
|
||||
var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`)
|
||||
|
||||
// shellStops are standalone tokens that terminate a command (pipes, redirects,
|
||||
// separators). Separators glued to a token (`get;`, `foo|`) are handled inline.
|
||||
var shellStops = map[string]bool{
|
||||
"|": true, "||": true, "&&": true, "&": true, ";": true,
|
||||
">": true, ">>": true, "<": true, "2>": true, "2>&1": true,
|
||||
}
|
||||
|
||||
// wordTrailPunct is sentence / CJK punctuation that can cling to a command word
|
||||
// in prose ("auth login." / "auth login,"); stripped so the word still resolves
|
||||
// instead of being dropped as an unknown command or non-ASCII narration.
|
||||
const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』`
|
||||
|
||||
// parseRefs extracts every lark-cli command reference from text (a shortcut's
|
||||
// Tips line, which may embed an "Example: lark-cli ..." command). It is
|
||||
// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits
|
||||
// in a ```bash fence, an inline `code` span, or bare prose. Backslash
|
||||
// line-continuations are joined first so a multi-line invocation is parsed as
|
||||
// one command; inline-code backticks and trailing # comments terminate it.
|
||||
func parseRefs(content string) []ref {
|
||||
var refs []ref
|
||||
lines := strings.Split(content, "\n")
|
||||
for i := 0; i < len(lines); i++ {
|
||||
lineNo := i + 1
|
||||
logical := lines[i]
|
||||
// Shell line continuation: a trailing backslash joins the next physical
|
||||
// line. Without this, flags on the continuation lines of a multi-line
|
||||
// `lark-cli ... \` example are never seen by the checker.
|
||||
for endsWithBackslash(logical) && i+1 < len(lines) {
|
||||
logical = strings.TrimRight(logical, " \t")
|
||||
logical = logical[:len(logical)-1] // drop the trailing backslash
|
||||
i++
|
||||
logical += " " + lines[i]
|
||||
}
|
||||
refs = append(refs, parseLine(logical, lineNo)...)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func endsWithBackslash(s string) bool {
|
||||
return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`)
|
||||
}
|
||||
|
||||
func parseLine(line string, lineNo int) []ref {
|
||||
var refs []ref
|
||||
rest := line
|
||||
for {
|
||||
idx := strings.Index(rest, cliToken)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
after := rest[idx+len(cliToken):]
|
||||
beforeOK := idx == 0 || isBoundary(rest[idx-1])
|
||||
afterOK := after == "" || isBoundary(after[0])
|
||||
if beforeOK && afterOK {
|
||||
if words, flags, raw, ok := parseCmd(after); ok {
|
||||
refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags})
|
||||
}
|
||||
}
|
||||
rest = after
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// parseCmd tokenizes the text following "lark-cli" into leading command words
|
||||
// (the subcommand path, up to the first flag) and flag tokens. It stops at a
|
||||
// shell separator (standalone or glued), an inline-code backtick, a comment, or
|
||||
// a placeholder/prose word. ok=false filters out non-commands.
|
||||
func parseCmd(after string) (words, flags []string, raw string, ok bool) {
|
||||
// An inline code span ends at the next backtick; a command never spans one.
|
||||
if i := strings.IndexByte(after, '`'); i >= 0 {
|
||||
after = after[:i]
|
||||
}
|
||||
// Drop $(...) command substitutions so flags belonging to the inner command
|
||||
// (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags.
|
||||
after = stripCmdSubst(after)
|
||||
|
||||
var kept []string
|
||||
inFlags := false
|
||||
for _, orig := range strings.Fields(after) {
|
||||
tok := orig
|
||||
if shellStops[tok] || strings.HasPrefix(tok, "#") {
|
||||
break
|
||||
}
|
||||
// A shell separator glued to a token ends the command mid-token
|
||||
// ("get;", "foo|next"): keep the part before it, handle it, then stop.
|
||||
stop := false
|
||||
if i := strings.IndexAny(tok, ";|"); i >= 0 {
|
||||
tok, stop = tok[:i], true
|
||||
}
|
||||
switch {
|
||||
case tok == "" || tok == "-":
|
||||
// empty (after a glued separator) or a bare stdin marker — skip
|
||||
case strings.HasPrefix(tok, "-"):
|
||||
if f := normalizeFlag(tok); f != "" {
|
||||
inFlags = true
|
||||
flags = append(flags, f)
|
||||
kept = append(kept, tok)
|
||||
}
|
||||
case inFlags:
|
||||
// positional / flag value after the first flag — not a command word
|
||||
kept = append(kept, tok)
|
||||
default:
|
||||
// Command-path word. ASCII placeholder markers (<x>, [x], {x|y},
|
||||
// +<verb>, ...) end the command — checked on the RAW token so the
|
||||
// trailing-punct stripping below cannot erase a "..." ellipsis
|
||||
// ("base +..." must stay a placeholder, not become "+").
|
||||
if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") {
|
||||
stop = true
|
||||
break
|
||||
}
|
||||
// Strip trailing sentence/CJK punctuation so "login." / "login,"
|
||||
// resolve to "login"; non-ASCII narration ends the command.
|
||||
w := strings.TrimRight(tok, wordTrailPunct)
|
||||
if w == "" || hasNonASCII(w) {
|
||||
stop = true
|
||||
break
|
||||
}
|
||||
words = append(words, w)
|
||||
kept = append(kept, tok)
|
||||
}
|
||||
if stop {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
raw = " " + strings.Join(kept, " ")
|
||||
}
|
||||
// Keep root-only refs ("lark-cli --help") and refs whose first word looks
|
||||
// like a subcommand; drop prose ("lark-cli 就能搞定 ...").
|
||||
if len(words) == 0 {
|
||||
return words, flags, raw, len(flags) > 0
|
||||
}
|
||||
if !subcommandStart.MatchString(words[0]) {
|
||||
return nil, nil, "", false
|
||||
}
|
||||
return words, flags, raw, true
|
||||
}
|
||||
|
||||
// stripCmdSubst removes $(...) command substitutions (including nested ones)
|
||||
// from s, leaving the surrounding text intact. Backtick substitutions are
|
||||
// already handled upstream (a command never spans a backtick).
|
||||
func stripCmdSubst(s string) string {
|
||||
var b strings.Builder
|
||||
depth := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
|
||||
depth = 1
|
||||
i++ // skip '('
|
||||
continue
|
||||
}
|
||||
if depth > 0 {
|
||||
switch s[i] {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
}
|
||||
continue
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isPlaceholderOrProse reports whether a command word is a doc placeholder
|
||||
// (<resource>, [flags], {a|b}, +<verb>, ...) or narration (CJK / other
|
||||
// non-ASCII), rather than a literal command token.
|
||||
func isPlaceholderOrProse(w string) bool {
|
||||
if hasNonASCII(w) {
|
||||
return true
|
||||
}
|
||||
return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...")
|
||||
}
|
||||
|
||||
func hasNonASCII(s string) bool {
|
||||
return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0
|
||||
}
|
||||
|
||||
// flagShape matches the leading flag token, stripping any trailing junk such as
|
||||
// a "=value" suffix or punctuation that bled in from the surrounding markdown
|
||||
// ("--help\"", "--help;", "--params={}"). The underscore is allowed because
|
||||
// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags.
|
||||
var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`)
|
||||
|
||||
// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not
|
||||
// a real flag (e.g. a shell-string fragment like "-草稿'").
|
||||
func normalizeFlag(tok string) string {
|
||||
return flagShape.FindString(tok)
|
||||
}
|
||||
|
||||
func isBoundary(b byte) bool {
|
||||
switch b {
|
||||
case ' ', '\t', '`', '(', ')', '\'', '"', '*':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// This file and its cmdexample_*_test.go siblings implement a test-only check:
|
||||
// the example commands embedded in shortcut definitions (the "Example: lark-cli
|
||||
// ..." lines in each shortcut's Tips, shown in --help) must match the real
|
||||
// command tree. It lives entirely in _test.go files (package cmd_test) so it
|
||||
// ships in no binary and is not importable by product code; the truth source is
|
||||
// cmd.Build, the same tree the binary uses, so the check cannot drift.
|
||||
//
|
||||
// It runs in the standard unit-test CI job (go test ./cmd/...). A mismatch — an
|
||||
// example using a renamed command or an unaccepted flag — fails that job.
|
||||
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// TestShortcutExampleCommands checks the example commands embedded in every
|
||||
// shortcut's Tips against the live command tree. A shortcut that defines no
|
||||
// example is simply skipped.
|
||||
//
|
||||
// Because the examples and the command definitions live in the same Go code,
|
||||
// this is a self-consistency check: any mismatch (an example using a renamed
|
||||
// command or a flag the command doesn't accept) is a bug to fix at the source.
|
||||
// It runs over all shortcuts — no baseline, no diff — since a wrong example is
|
||||
// always a defect, never acceptable "pre-existing drift".
|
||||
func TestShortcutExampleCommands(t *testing.T) {
|
||||
// Reproducibility: use the embedded API metadata (not a developer's stale
|
||||
// ~/.lark-cli remote cache, which can miss commands) and an empty config
|
||||
// dir so local strict mode / plugins / policy cannot reshape the tree.
|
||||
// t.Setenv auto-restores after the test, so other cmd tests are unaffected.
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
cat := buildCmdExampleCatalog()
|
||||
|
||||
type located struct {
|
||||
shortcut string
|
||||
f finding
|
||||
}
|
||||
var findings []located
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
var refs []ref
|
||||
for _, tip := range sc.Tips {
|
||||
refs = append(refs, parseRefs(tip)...)
|
||||
}
|
||||
label := strings.TrimSpace(sc.Service + " " + sc.Command)
|
||||
for _, f := range checkRefs(cat, refs) {
|
||||
findings = append(findings, located{shortcut: label, f: f})
|
||||
}
|
||||
}
|
||||
|
||||
if len(findings) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut })
|
||||
for _, lf := range findings {
|
||||
hint := ""
|
||||
if lf.f.suggest != "" {
|
||||
hint = " (did you mean " + lf.f.suggest + "?)"
|
||||
}
|
||||
if lf.f.kind == unknownFlag {
|
||||
t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s",
|
||||
lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
|
||||
} else {
|
||||
t.Errorf("shortcut %q example uses unknown command %q%s\n %s",
|
||||
lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
|
||||
}
|
||||
}
|
||||
t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+
|
||||
"fix the Example in the shortcut definition.", len(findings))
|
||||
}
|
||||
|
||||
// buildCmdExampleCatalog walks the live cobra command tree and records every
|
||||
// command path (minus the "lark-cli" root prefix) with its accepted flags and
|
||||
// whether it is a parent group. This is the same Build() the binary uses, so
|
||||
// the catalog can never drift from the real commands.
|
||||
func buildCmdExampleCatalog() *catalog {
|
||||
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
|
||||
cat := newCatalog()
|
||||
var walk func(c *cobra.Command)
|
||||
walk = func(c *cobra.Command) {
|
||||
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
|
||||
var flags []string
|
||||
add := func(fl *pflag.Flag) {
|
||||
flags = append(flags, "--"+fl.Name)
|
||||
if fl.Shorthand != "" {
|
||||
flags = append(flags, "-"+fl.Shorthand)
|
||||
}
|
||||
}
|
||||
c.Flags().VisitAll(add)
|
||||
c.InheritedFlags().VisitAll(add)
|
||||
c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile)
|
||||
cat.addCommand(path, flags)
|
||||
cat.setGroup(path, c.HasSubCommands())
|
||||
for _, sub := range c.Commands() {
|
||||
walk(sub)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return cat
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testCatalog() *catalog {
|
||||
c := newCatalog()
|
||||
c.addCommand("", []string{"--profile"}) // root
|
||||
c.setGroup("", true)
|
||||
c.addCommand("contact", []string{"--profile"})
|
||||
c.setGroup("contact", true)
|
||||
c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"})
|
||||
c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands)
|
||||
c.addCommand("mail", nil)
|
||||
c.setGroup("mail", true)
|
||||
c.addCommand("mail user_mailbox.messages", []string{"--profile"})
|
||||
c.setGroup("mail user_mailbox.messages", true)
|
||||
c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"})
|
||||
return c
|
||||
}
|
||||
|
||||
func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) {
|
||||
c := testCatalog()
|
||||
if !c.hasCommand("contact +search-user") {
|
||||
t.Fatal("expected contact +search-user to exist")
|
||||
}
|
||||
if c.hasCommand("contact +nope") {
|
||||
t.Fatal("did not expect contact +nope")
|
||||
}
|
||||
if !c.hasFlag("contact +search-user", "--query") {
|
||||
t.Fatal("--query should be valid")
|
||||
}
|
||||
if c.hasFlag("contact +search-user", "--nope") {
|
||||
t.Fatal("--nope should be invalid")
|
||||
}
|
||||
// universal flags pass on any command
|
||||
for _, f := range []string{"--help", "-h", "--version"} {
|
||||
if !c.hasFlag("contact +search-user", f) {
|
||||
t.Fatalf("universal flag %s should pass", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleLongestPrefix(t *testing.T) {
|
||||
c := testCatalog()
|
||||
tests := []struct {
|
||||
words []string
|
||||
want string
|
||||
wantN int
|
||||
wantOK bool
|
||||
}{
|
||||
{[]string{"contact", "+search-user"}, "contact +search-user", 2, true},
|
||||
{[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals
|
||||
{[]string{"nope"}, "", 0, false},
|
||||
{nil, "", 0, true}, // empty -> root
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, n, ok := c.longestPrefix(tt.words)
|
||||
if got != tt.want || n != tt.wantN || ok != tt.wantOK {
|
||||
t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)",
|
||||
tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refWordsOf(refs []ref) [][]string {
|
||||
var out [][]string
|
||||
for _, r := range refs {
|
||||
out = append(out, r.words)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCmdExampleParseRefsExtractsCommands(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
"运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code
|
||||
"```bash",
|
||||
"lark-cli api GET /open-apis/x --params '{}'", // bash block
|
||||
"```",
|
||||
"用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command
|
||||
"npx foo | lark-cli api GET /y", // after a pipe
|
||||
}, "\n")
|
||||
refs := parseRefs(content)
|
||||
if len(refs) != 4 {
|
||||
t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs))
|
||||
}
|
||||
if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" ||
|
||||
len(got.flags) != 1 || got.flags[0] != "--query" {
|
||||
t.Errorf("ref0 = %+v", got)
|
||||
}
|
||||
if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" {
|
||||
t.Errorf("ref1 words = %v", got.words)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) {
|
||||
// A line whose first word is prose yields no command at all.
|
||||
if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 {
|
||||
t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs))
|
||||
}
|
||||
// Syntax templates / trailing prose may leave a real leading word ("mail"),
|
||||
// but no placeholder or CJK token may leak into the command words — that is
|
||||
// what prevents false positives like an "<resource>" unknown-command report.
|
||||
for _, line := range []string{
|
||||
"lark-cli mail <resource> <method> [flags]",
|
||||
"lark-cli apps +<verb> [flags]",
|
||||
"lark-cli base +...",
|
||||
"lark-cli mail 写信场景下的格式说明",
|
||||
} {
|
||||
for _, r := range parseRefs(line) {
|
||||
for _, w := range r.words {
|
||||
if isPlaceholderOrProse(w) {
|
||||
t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) {
|
||||
// frontmatter-style quoted value: the trailing quote must not bleed into the flag
|
||||
refs := parseRefs(`cliHelp: "lark-cli contact --help"`)
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("expected 1 ref, got %d", len(refs))
|
||||
}
|
||||
if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" {
|
||||
t.Errorf("expected flag --help, got %v", refs[0].flags)
|
||||
}
|
||||
// bare "-" (stdin marker) and "=value" suffix
|
||||
refs = parseRefs("lark-cli api GET /x --params={} --data -")
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("expected 1 ref, got %d", len(refs))
|
||||
}
|
||||
flags := strings.Join(refs[0].flags, " ")
|
||||
if flags != "--params --data" {
|
||||
t.Errorf("expected '--params --data', got %q", flags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleCheck(t *testing.T) {
|
||||
c := testCatalog()
|
||||
tests := []struct {
|
||||
name string
|
||||
r ref
|
||||
wantKind string // "" = no finding
|
||||
wantPath string
|
||||
}{
|
||||
{"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""},
|
||||
{"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""},
|
||||
{"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"},
|
||||
{"group leftover = unknown subcommand",
|
||||
ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}},
|
||||
unknownCommand, "mail user_mailbox.messages batch_modify_message"},
|
||||
{"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"},
|
||||
{"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fs := checkRefs(c, []ref{tt.r})
|
||||
if tt.wantKind == "" {
|
||||
if len(fs) != 0 {
|
||||
t.Fatalf("expected no finding, got %+v", fs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(fs) != 1 {
|
||||
t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs)
|
||||
}
|
||||
if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath {
|
||||
t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExampleCheckSuggestsNearest(t *testing.T) {
|
||||
c := testCatalog()
|
||||
fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}})
|
||||
if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" {
|
||||
t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after
|
||||
// review: backslash continuation, underscore flags, $(...) substitution, glued
|
||||
// separators, trailing punctuation, and the "..." placeholder.
|
||||
func TestCmdExampleParseRefsRobustness(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, content, wantWords, wantFlags string
|
||||
wantRefs int
|
||||
}{
|
||||
{"backslash continuation joins flags",
|
||||
"lark-cli contact +search-user \\\n --query foo \\\n --as user",
|
||||
"contact +search-user", "--query --as", 1},
|
||||
{"underscore flag not truncated",
|
||||
"lark-cli whiteboard +update --input_format mermaid",
|
||||
"whiteboard +update", "--input_format", 1},
|
||||
{"command-substitution flags ignored",
|
||||
`lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`,
|
||||
"slides x create", "--data --as", 1},
|
||||
{"glued separator truncates",
|
||||
"lark-cli auth login; echo done",
|
||||
"auth login", "", 1},
|
||||
{"trailing CJK punctuation stripped",
|
||||
"用 lark-cli auth login。",
|
||||
"auth login", "", 1},
|
||||
{"ellipsis placeholder stays placeholder",
|
||||
"lark-cli base +...",
|
||||
"base", "", 1},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
refs := parseRefs(tt.content)
|
||||
if len(refs) != tt.wantRefs {
|
||||
t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs))
|
||||
}
|
||||
if tt.wantRefs == 0 {
|
||||
return
|
||||
}
|
||||
if got := strings.Join(refs[0].words, " "); got != tt.wantWords {
|
||||
t.Errorf("words=%q want %q", got, tt.wantWords)
|
||||
}
|
||||
if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags {
|
||||
t.Errorf("flags=%q want %q", got, tt.wantFlags)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the
|
||||
// service command tree for any depth — flat dotted resources AND genuinely
|
||||
// nested resources — so it round-trips through apicatalog.Resolve instead of
|
||||
// assuming a fixed root->service->resource->method shape.
|
||||
func TestCommandCatalogPath(t *testing.T) {
|
||||
chain := func(names ...string) *cobra.Command {
|
||||
var parent, leaf *cobra.Command
|
||||
for _, n := range names {
|
||||
c := &cobra.Command{Use: n}
|
||||
if parent != nil {
|
||||
parent.AddCommand(c)
|
||||
}
|
||||
parent = c
|
||||
leaf = c
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
leaf *cobra.Command
|
||||
want []string
|
||||
}{
|
||||
{"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}},
|
||||
{"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}},
|
||||
{"service level", chain("lark-cli", "im"), []string{"im"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("commandCatalogPath = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The root command (no parent) has no catalog path.
|
||||
if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 {
|
||||
t.Errorf("root path = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
package completion
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -31,13 +32,10 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
|
||||
case "powershell":
|
||||
return root.GenPowerShellCompletionWithDesc(out)
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported shell: %s", args[0]).
|
||||
WithHint("supported shells: bash, zsh, fish, powershell")
|
||||
return fmt.Errorf("unsupported shell: %s", args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -12,10 +12,8 @@ import (
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -39,10 +37,8 @@ type BindOptions struct {
|
||||
// this flag because its own prompts already require human confirmation.
|
||||
Force bool
|
||||
|
||||
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateBindFlags
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
|
||||
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
|
||||
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
|
||||
// the account being bound. Populated after resolveAccount; TUI stages
|
||||
@@ -59,14 +55,14 @@ type BindOptions struct {
|
||||
|
||||
// NewCmdConfigBind creates the config bind subcommand.
|
||||
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
|
||||
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
|
||||
opts := &BindOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "bind",
|
||||
Short: "Bind Agent config to a workspace (source / app-id / force)",
|
||||
Long: `Bind an AI Agent's (OpenClaw / Hermes / Lark Channel) Feishu credentials to a lark-cli workspace.
|
||||
Long: `Bind an AI Agent's (OpenClaw / Hermes) Feishu credentials to a lark-cli workspace.
|
||||
|
||||
--source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME / LARK_CHANNEL); pass it only to override.
|
||||
--source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME); pass it only to override.
|
||||
|
||||
For AI agents — DO NOT bind without user confirmation. Binding may
|
||||
overwrite an existing one and locks in an identity policy. Ask the user:
|
||||
@@ -89,7 +85,6 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
Example: ` # AI flow: confirm intent + identity with user FIRST, then run:
|
||||
lark-cli config bind --source openclaw --app-id <id> --identity bot-only
|
||||
lark-cli config bind --source hermes --identity user-default
|
||||
lark-cli config bind --source lark-channel
|
||||
|
||||
# Interactive (terminal user) — TUI prompts for everything:
|
||||
lark-cli config bind`,
|
||||
@@ -102,12 +97,11 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes|lark-channel); auto-detected from env signals when omitted")
|
||||
cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes); auto-detected from env signals when omitted")
|
||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
|
||||
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -151,7 +145,7 @@ func configBindRun(opts *BindOptions) error {
|
||||
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
|
||||
applyPreferences(appConfig, opts)
|
||||
noticeUserDefaultRisk(opts)
|
||||
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
||||
@@ -181,8 +175,8 @@ type existingBinding struct {
|
||||
// fall back to a TUI prompt (TUI mode) or an error (flag mode).
|
||||
func finalizeSource(opts *BindOptions) (string, error) {
|
||||
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
|
||||
if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit).WithParam("--source")
|
||||
if explicit != "" && explicit != "openclaw" && explicit != "hermes" {
|
||||
return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes", explicit)
|
||||
}
|
||||
|
||||
var detected string
|
||||
@@ -191,31 +185,29 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
||||
detected = "openclaw"
|
||||
case core.WorkspaceHermes:
|
||||
detected = "hermes"
|
||||
case core.WorkspaceLarkChannel:
|
||||
detected = "lark-channel"
|
||||
}
|
||||
|
||||
// Explicit and env detection must agree when both are present. Reject
|
||||
// before any interactive prompts — running inside Hermes with
|
||||
// --source openclaw (or vice versa) is almost always a mistake.
|
||||
if explicit != "" && detected != "" && explicit != detected {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--source %q does not match detected Agent environment (%s)", explicit, detected).
|
||||
WithHint("remove --source to auto-detect, or run this command in the correct Agent context").
|
||||
WithParam("--source")
|
||||
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
||||
fmt.Sprintf("--source %q does not match detected Agent environment (%s)", explicit, detected),
|
||||
"remove --source to auto-detect, or run this command in the correct Agent context")
|
||||
}
|
||||
|
||||
// TUI: prompt for language before any downstream prompts. The source
|
||||
// selection itself may still be skipped entirely if --source or the
|
||||
// env already pinned it. Picker offers 2 options (中文 / English) and
|
||||
// drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering).
|
||||
// env already pinned it.
|
||||
if opts.IsTUI && !opts.langExplicit {
|
||||
lang, err := promptLangSelection()
|
||||
lang, err := promptLangSelection("")
|
||||
if err != nil {
|
||||
return "", langSelectionError(err)
|
||||
if err == huh.ErrUserAborted {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
opts.Lang = string(lang)
|
||||
opts.UILang = lang
|
||||
opts.Lang = lang
|
||||
}
|
||||
|
||||
if explicit != "" {
|
||||
@@ -227,10 +219,9 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
||||
if opts.IsTUI {
|
||||
return tuiSelectSource(opts)
|
||||
}
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"cannot determine Agent source: no --source flag and no Agent environment detected").
|
||||
WithHint("pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context").
|
||||
WithParam("--source")
|
||||
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
||||
"cannot determine Agent source: no --source flag and no Agent environment detected",
|
||||
"pass --source openclaw|hermes, or run this command inside an OpenClaw or Hermes chat")
|
||||
}
|
||||
|
||||
// reconcileExistingBinding reads any existing config at configPath and decides
|
||||
@@ -250,7 +241,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
|
||||
return existingBinding{}, err
|
||||
}
|
||||
if action == "cancel" {
|
||||
msg := getBindMsg(opts.UILang)
|
||||
msg := getBindMsg(opts.Lang)
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
|
||||
return existingBinding{Cancelled: true}, nil
|
||||
}
|
||||
@@ -334,10 +325,9 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
|
||||
if !hasStrictBotLock(previousConfigBytes) {
|
||||
return nil
|
||||
}
|
||||
msg := getBindMsg(opts.UILang)
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite,
|
||||
"config bind --force", "%s", msg.IdentityEscalationMessage).
|
||||
WithHint("%s", msg.IdentityEscalationHint)
|
||||
msg := getBindMsg(opts.Lang)
|
||||
return output.ErrWithHint(output.ExitValidation, "bind",
|
||||
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
|
||||
}
|
||||
|
||||
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
|
||||
@@ -353,23 +343,14 @@ func noticeUserDefaultRisk(opts *BindOptions) {
|
||||
if opts.IsTUI || opts.Identity != "user-default" {
|
||||
return
|
||||
}
|
||||
msg := getBindMsg(opts.UILang)
|
||||
msg := getBindMsg(opts.Lang)
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
|
||||
}
|
||||
|
||||
// applyPreferences expands the chosen identity preset into the underlying
|
||||
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
|
||||
// profile's intent survives later changes to global strict-mode settings.
|
||||
// preferredLang resolves the language to persist: the requested value when set,
|
||||
// otherwise the prior one — so an unset --lang never clears a stored preference.
|
||||
func preferredLang(requested, prior i18n.Lang) i18n.Lang {
|
||||
if requested != "" {
|
||||
return requested
|
||||
}
|
||||
return prior
|
||||
}
|
||||
|
||||
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
|
||||
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
|
||||
switch opts.Identity {
|
||||
case "bot-only":
|
||||
sm := core.StrictModeBot
|
||||
@@ -380,23 +361,9 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.L
|
||||
appConfig.StrictMode = &sm
|
||||
appConfig.DefaultAs = core.AsUser
|
||||
}
|
||||
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
|
||||
}
|
||||
|
||||
// priorLang returns the language preference recorded in a previous config, or
|
||||
// "" if there is none / the bytes don't parse. Reads from CurrentApp (or Apps[0]
|
||||
// fallback) — scanning all apps for the first non-empty Lang would leak the
|
||||
// wrong profile's preference into a re-bind when the workspace holds multiple
|
||||
// named profiles and the active one disagrees with Apps[0].
|
||||
func priorLang(previousConfigBytes []byte) i18n.Lang {
|
||||
var multi core.MultiAppConfig
|
||||
if json.Unmarshal(previousConfigBytes, &multi) != nil {
|
||||
return ""
|
||||
if opts.Lang != "" {
|
||||
appConfig.Lang = opts.Lang
|
||||
}
|
||||
if app := multi.CurrentAppConfig(""); app != nil {
|
||||
return app.Lang
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// commitBinding finalizes the bind: atomic write of the new workspace config,
|
||||
@@ -408,21 +375,21 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
||||
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
||||
|
||||
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "bind",
|
||||
"failed to create workspace directory: %v", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(multi, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "bind",
|
||||
"failed to marshal config: %v", err)
|
||||
}
|
||||
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "bind",
|
||||
"failed to write config %s: %v", configPath, err)
|
||||
}
|
||||
|
||||
replaced := previousConfigBytes != nil
|
||||
// uiMsg renders human-facing TUI text (stderr success banner). Follows
|
||||
// opts.UILang — zh by default; picker can flip it to en. --lang does
|
||||
// not influence the TUI language.
|
||||
uiMsg := getBindMsg(opts.UILang)
|
||||
msg := getBindMsg(opts.Lang)
|
||||
display := sourceDisplayName(source)
|
||||
|
||||
if replaced {
|
||||
@@ -430,11 +397,7 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
||||
}
|
||||
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
|
||||
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice)
|
||||
|
||||
if opts.langExplicit && opts.Lang != "" {
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(uiMsg.LangPreferenceSet, opts.Lang))
|
||||
}
|
||||
fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice)
|
||||
|
||||
// TUI mode is a human sitting at a terminal; the BindSuccess notice on
|
||||
// stderr is enough and a machine-readable JSON dump on stdout is just
|
||||
@@ -452,17 +415,12 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
||||
"replaced": replaced,
|
||||
"identity": opts.Identity,
|
||||
}
|
||||
// JSON "message" follows the effective preference on disk (appConfig.Lang),
|
||||
// not the raw --lang value: when --lang is omitted on re-bind, preferredLang
|
||||
// has already inherited the prior preference into appConfig.Lang, and the
|
||||
// message should respect that inherited choice. stderr above follows UILang.
|
||||
prefMsg := getBindMsg(appConfig.Lang)
|
||||
brand := brandDisplay(string(appConfig.Brand), appConfig.Lang)
|
||||
brand := brandDisplay(string(appConfig.Brand), opts.Lang)
|
||||
switch opts.Identity {
|
||||
case "bot-only":
|
||||
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
|
||||
envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand)
|
||||
case "user-default":
|
||||
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
|
||||
envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display)
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(envelope)
|
||||
@@ -499,7 +457,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core
|
||||
|
||||
// tuiSelectSource prompts user to choose bind source.
|
||||
func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
msg := getBindMsg(opts.UILang)
|
||||
msg := getBindMsg(opts.Lang)
|
||||
var source string
|
||||
|
||||
// Pre-select based on detected env signals
|
||||
@@ -509,8 +467,6 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
source = "openclaw"
|
||||
case core.WorkspaceHermes:
|
||||
source = "hermes"
|
||||
case core.WorkspaceLarkChannel:
|
||||
source = "lark-channel"
|
||||
default:
|
||||
source = "openclaw" // default first option
|
||||
}
|
||||
@@ -518,17 +474,15 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
// Resolve actual paths for display
|
||||
openclawPath := resolveOpenClawConfigPath()
|
||||
hermesEnvPath := resolveHermesEnvPath()
|
||||
larkChannelPath := resolveLarkChannelConfigPath()
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title(msg.SelectSource).
|
||||
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))).
|
||||
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))).
|
||||
Options(
|
||||
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
|
||||
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
|
||||
huh.NewOption(fmt.Sprintf(msg.SourceLarkChannel, larkChannelPath), "lark-channel"),
|
||||
).
|
||||
Value(&source),
|
||||
),
|
||||
@@ -546,7 +500,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
// tuiSelectApp prompts the user to choose from multiple account candidates.
|
||||
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
|
||||
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
|
||||
msg := getBindMsg(opts.UILang)
|
||||
msg := getBindMsg(opts.Lang)
|
||||
options := make([]huh.Option[int], 0, len(candidates))
|
||||
for i, c := range candidates {
|
||||
label := c.AppID
|
||||
@@ -560,7 +514,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.UILang))).
|
||||
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))).
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
@@ -577,7 +531,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
|
||||
|
||||
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
|
||||
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
|
||||
msg := getBindMsg(opts.UILang)
|
||||
msg := getBindMsg(opts.Lang)
|
||||
|
||||
// Build existing binding summary
|
||||
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
|
||||
@@ -626,14 +580,9 @@ func validateBindFlags(opts *BindOptions) error {
|
||||
switch opts.Identity {
|
||||
case "bot-only", "user-default":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity")
|
||||
return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity)
|
||||
}
|
||||
}
|
||||
lang, err := cmdutil.ParseLangFlag(opts.Lang)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Lang = string(lang)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -649,8 +598,8 @@ func validateBindFlags(opts *BindOptions) error {
|
||||
// DescriptionFunc approach breaks here because a longer description on
|
||||
// hover pushes options out of the field's initial viewport.
|
||||
func tuiSelectIdentity(opts *BindOptions) (string, error) {
|
||||
msg := getBindMsg(opts.UILang)
|
||||
brand := brandDisplay(opts.Brand, opts.UILang)
|
||||
msg := getBindMsg(opts.Lang)
|
||||
brand := brandDisplay(opts.Brand, opts.Lang)
|
||||
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
|
||||
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
|
||||
var value string
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
package config
|
||||
|
||||
import "github.com/larksuite/cli/internal/i18n"
|
||||
|
||||
// bindMsg holds all TUI text for config bind, supporting zh/en via --lang.
|
||||
//
|
||||
// Brand-aware strings use a %s slot where the UI-friendly product name
|
||||
@@ -14,11 +12,10 @@ import "github.com/larksuite/cli/internal/i18n"
|
||||
type bindMsg struct {
|
||||
// Source selection.
|
||||
// SelectSourceDesc format: brand.
|
||||
SelectSource string
|
||||
SelectSourceDesc string
|
||||
SourceOpenClaw string // format: resolved config path.
|
||||
SourceHermes string // format: resolved dotenv path.
|
||||
SourceLarkChannel string // format: resolved config path.
|
||||
SelectSource string
|
||||
SelectSourceDesc string
|
||||
SourceOpenClaw string // format: resolved config path.
|
||||
SourceHermes string // format: resolved dotenv path.
|
||||
|
||||
// Account selection (OpenClaw multi-account).
|
||||
// Format: source display name ("OpenClaw" | "Hermes"), brand.
|
||||
@@ -86,19 +83,13 @@ type bindMsg struct {
|
||||
// require in-flow human confirmation.
|
||||
IdentityEscalationMessage string
|
||||
IdentityEscalationHint string
|
||||
|
||||
// LangPreferenceSet is printed to stderr after a successful bind when the
|
||||
// user explicitly passed --lang. Format: language code. Not printed when
|
||||
// --lang was not explicit (i.e., the cobra default zh stayed in effect).
|
||||
LangPreferenceSet string
|
||||
}
|
||||
|
||||
var bindMsgZh = &bindMsg{
|
||||
SelectSource: "你想在哪个 Agent 中使用 lark-cli?",
|
||||
SelectSourceDesc: "从你选择的 Agent 中获取%s应用信息,并配置到 lark-cli 中",
|
||||
SourceOpenClaw: "OpenClaw — 配置文件: %s",
|
||||
SourceHermes: "Hermes — 配置文件: %s",
|
||||
SourceLarkChannel: "Lark Channel — 配置文件: %s",
|
||||
SelectSource: "你想在哪个 Agent 中使用 lark-cli?",
|
||||
SelectSourceDesc: "从你选择的 Agent 中获取%s应用信息,并配置到 lark-cli 中",
|
||||
SourceOpenClaw: "OpenClaw — 配置文件: %s",
|
||||
SourceHermes: "Hermes — 配置文件: %s",
|
||||
|
||||
SelectAccount: "检测到 %s 中已配置多个%s应用,请选择一个",
|
||||
|
||||
@@ -123,16 +114,13 @@ var bindMsgZh = &bindMsg{
|
||||
|
||||
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
|
||||
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
|
||||
|
||||
LangPreferenceSet: "语言偏好已设置:%s",
|
||||
}
|
||||
|
||||
var bindMsgEn = &bindMsg{
|
||||
SelectSource: "Which Agent are you running?",
|
||||
SelectSourceDesc: "lark-cli will read your %s app credentials from the selected Agent and apply them automatically.",
|
||||
SourceOpenClaw: "OpenClaw — config: %s",
|
||||
SourceHermes: "Hermes — config: %s",
|
||||
SourceLarkChannel: "Lark Channel — config: %s",
|
||||
SelectSource: "Which Agent are you running?",
|
||||
SelectSourceDesc: "lark-cli will read your %s app credentials from the selected Agent and apply them automatically.",
|
||||
SourceOpenClaw: "OpenClaw — config: %s",
|
||||
SourceHermes: "Hermes — config: %s",
|
||||
|
||||
// Args order (source, brand) matches the Chinese template; %[N]s lets the
|
||||
// English reading order differ while the caller passes args in one order.
|
||||
@@ -159,13 +147,10 @@ var bindMsgEn = &bindMsg{
|
||||
|
||||
IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.",
|
||||
IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`",
|
||||
|
||||
LangPreferenceSet: "Language preference set to: %s",
|
||||
}
|
||||
|
||||
// getBindMsg picks the zh/en TUI bundle; non-English falls back to zh.
|
||||
func getBindMsg(lang i18n.Lang) *bindMsg {
|
||||
if lang.IsEnglish() {
|
||||
func getBindMsg(lang string) *bindMsg {
|
||||
if lang == "en" {
|
||||
return bindMsgEn
|
||||
}
|
||||
return bindMsgZh
|
||||
@@ -176,11 +161,11 @@ func getBindMsg(lang i18n.Lang) *bindMsg {
|
||||
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en —
|
||||
// this is the safe default when the brand hasn't been resolved yet (for
|
||||
// example, on the pre-binding source-selection screen).
|
||||
func brandDisplay(brand string, lang i18n.Lang) string {
|
||||
func brandDisplay(brand, lang string) string {
|
||||
if brand == "lark" || brand == "Lark" || brand == "LARK" {
|
||||
return "Lark"
|
||||
}
|
||||
if lang.IsEnglish() {
|
||||
if lang == "en" {
|
||||
return "Feishu"
|
||||
}
|
||||
return "飞书"
|
||||
|
||||
@@ -13,53 +13,30 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// wantErrDetail is the normalized comparison shape for a typed error's wire
|
||||
// fields: Type is the error's Category string ("validation", "config", ...),
|
||||
// alongside Message and Hint.
|
||||
type wantErrDetail struct {
|
||||
Type string
|
||||
Message string
|
||||
Hint string
|
||||
}
|
||||
|
||||
// assertExitError checks the full structured error in one assertion against a
|
||||
// typed error (ValidationError or ConfigError), normalizing its Category /
|
||||
// Message / Hint to wantDetail.
|
||||
func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDetail) {
|
||||
// assertExitError checks the full structured error in one assertion.
|
||||
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
if got := output.ExitCodeOf(err); got != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", got, wantCode)
|
||||
}
|
||||
gotDetail := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
if !reflect.DeepEqual(gotDetail, wantDetail) {
|
||||
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
||||
}
|
||||
return
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
|
||||
}
|
||||
var ce *errs.ConfigError
|
||||
if errors.As(err, &ce) {
|
||||
if got := output.ExitCodeOf(err); got != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", got, wantCode)
|
||||
}
|
||||
gotDetail := wantErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
|
||||
if !reflect.DeepEqual(gotDetail, wantDetail) {
|
||||
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
||||
}
|
||||
return
|
||||
if exitErr.Code != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil error detail")
|
||||
}
|
||||
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
|
||||
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
|
||||
}
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
|
||||
}
|
||||
|
||||
// assertEnvelope decodes stdout and checks it matches want exactly — every key
|
||||
@@ -128,235 +105,14 @@ func TestConfigBindCmd_LangDefault(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.Lang != "" {
|
||||
t.Errorf("Lang = %q, want default %q (unset)", gotOpts.Lang, "")
|
||||
if gotOpts.Lang != "zh" {
|
||||
t.Errorf("Lang = %q, want default %q", gotOpts.Lang, "zh")
|
||||
}
|
||||
if gotOpts.langExplicit {
|
||||
t.Error("expected langExplicit=false when --lang not passed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigBindRun_InvalidLang verifies a non-empty --lang is strictly
|
||||
// validated: wrong case, typos, and removed codes all exit with
|
||||
// ExitValidation (code 2) and a message identifying the offending value.
|
||||
// (Empty is not invalid — see TestConfigBindRun_EmptyLangIsNoOp.)
|
||||
func TestConfigBindRun_InvalidLang(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
hermesHome := t.TempDir()
|
||||
t.Setenv("HERMES_HOME", hermesHome)
|
||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
||||
t.Fatalf("write .env: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
lang string
|
||||
}{
|
||||
{"wrong case ZH", "ZH"},
|
||||
{"typo frr", "frr"},
|
||||
{"removed code ar", "ar"},
|
||||
{"unknown xx", "xx"},
|
||||
{"hyphen form zh-CN", "zh-CN"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{
|
||||
Factory: f,
|
||||
Source: "hermes",
|
||||
Lang: tc.lang,
|
||||
langExplicit: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if valErr.Param != "--lang" {
|
||||
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid --lang") {
|
||||
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigBindRun_EmptyLangIsNoOp verifies that an empty --lang (omitted or
|
||||
// explicit "") is unset: it neither errors nor persists a language, while a
|
||||
// non-empty short code or Feishu locale both canonicalize to the same locale.
|
||||
func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
lang string
|
||||
explicit bool
|
||||
wantLang i18n.Lang
|
||||
}{
|
||||
{"omitted", "", false, ""},
|
||||
{"explicit empty", "", true, ""},
|
||||
{"short code", "ja", true, i18n.LangJaJP},
|
||||
{"feishu locale", "ja_jp", true, i18n.LangJaJP},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
hermesHome := t.TempDir()
|
||||
t.Setenv("HERMES_HOME", hermesHome)
|
||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
||||
t.Fatalf("write .env: %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{
|
||||
Factory: f,
|
||||
Source: "hermes",
|
||||
Lang: tc.lang,
|
||||
langExplicit: tc.explicit,
|
||||
}); err != nil {
|
||||
t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app == nil {
|
||||
t.Fatal("no app persisted")
|
||||
}
|
||||
if app.Lang != tc.wantLang {
|
||||
t.Errorf("persisted Lang = %q, want %q", app.Lang, tc.wantLang)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigBindRun_OmitLangPreservesPrior guards against a re-bind without
|
||||
// --lang silently dropping a previously stored preference (appConfig is rebuilt
|
||||
// fresh, so commitBinding must inherit the prior Lang).
|
||||
func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
hermesHome := t.TempDir()
|
||||
t.Setenv("HERMES_HOME", hermesHome)
|
||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
||||
t.Fatalf("write .env: %v", err)
|
||||
}
|
||||
|
||||
f1, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "ja", langExplicit: true}); err != nil {
|
||||
t.Fatalf("first bind (--lang ja): %v", err)
|
||||
}
|
||||
f2, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
|
||||
t.Fatalf("re-bind (no --lang): %v", err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
if app := multi.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
|
||||
t.Errorf("Lang after re-bind = %v, want %q (preserved)", app, i18n.LangJaJP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPriorLang_RespectsCurrentApp guards against priorLang scanning all apps
|
||||
// and silently returning a non-current profile's Lang. In a multi-profile
|
||||
// workspace (set up via `profile add` before a re-bind), the active profile's
|
||||
// Lang must win over a sibling profile that happens to sit earlier in the slice.
|
||||
func TestPriorLang_RespectsCurrentApp(t *testing.T) {
|
||||
multi := core.MultiAppConfig{
|
||||
CurrentApp: "active",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP},
|
||||
{Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS},
|
||||
},
|
||||
}
|
||||
bytes, err := json.Marshal(multi)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if got := priorLang(bytes); got != i18n.LangEnUS {
|
||||
t.Errorf("priorLang = %q, want %q (must follow CurrentApp, not Apps[0])", got, i18n.LangEnUS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPriorLang_FallsBackToFirstAppWhenCurrentUnset covers the legacy
|
||||
// single-app shape (no CurrentApp): CurrentAppConfig falls back to Apps[0],
|
||||
// so a bind-written config (which always has exactly one app and no
|
||||
// CurrentApp field) still inherits its Lang.
|
||||
func TestPriorLang_FallsBackToFirstAppWhenCurrentUnset(t *testing.T) {
|
||||
multi := core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{AppId: "cli_only", Lang: i18n.LangJaJP},
|
||||
},
|
||||
}
|
||||
bytes, err := json.Marshal(multi)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if got := priorLang(bytes); got != i18n.LangJaJP {
|
||||
t.Errorf("priorLang = %q, want %q", got, i18n.LangJaJP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPriorLang_MalformedReturnsEmpty exercises the unparseable-bytes branch.
|
||||
func TestPriorLang_MalformedReturnsEmpty(t *testing.T) {
|
||||
if got := priorLang([]byte("not json")); got != "" {
|
||||
t.Errorf("priorLang(malformed) = %q, want \"\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigBindRun_EnvelopeMessageFollowsInheritedLang guards the JSON envelope
|
||||
// "message" field against regressing to opts.Lang: when --lang is omitted on
|
||||
// re-bind, the inherited preference (appConfig.Lang) must drive the message
|
||||
// language and the embedded brand display — otherwise an AI agent that set
|
||||
// English on first bind sees Chinese in every subsequent re-bind envelope.
|
||||
func TestConfigBindRun_EnvelopeMessageFollowsInheritedLang(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
hermesHome := t.TempDir()
|
||||
t.Setenv("HERMES_HOME", hermesHome)
|
||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
||||
t.Fatalf("write .env: %v", err)
|
||||
}
|
||||
|
||||
f1, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "en", langExplicit: true}); err != nil {
|
||||
t.Fatalf("first bind (--lang en): %v", err)
|
||||
}
|
||||
|
||||
f2, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
|
||||
t.Fatalf("re-bind (no --lang): %v", err)
|
||||
}
|
||||
|
||||
envelope := map[string]any{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v", err)
|
||||
}
|
||||
msg, _ := envelope["message"].(string)
|
||||
enMsg := getBindMsg(i18n.LangEnUS)
|
||||
wantMsg := fmt.Sprintf(enMsg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", i18n.LangEnUS))
|
||||
if msg != wantMsg {
|
||||
t.Errorf("envelope.message = %q,\nwant %q (must follow inherited appConfig.Lang=en_us, not raw opts.Lang)", msg, wantMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Run function tests (aligned with TestConfigShowRun pattern) ──
|
||||
|
||||
func TestConfigBindRun_InvalidSource(t *testing.T) {
|
||||
@@ -365,9 +121,9 @@ func TestConfigBindRun_InvalidSource(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`,
|
||||
Message: `invalid --source "invalid"; valid values: openclaw, hermes`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -382,32 +138,24 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
// TestFactory has IsTerminal=false by default
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: ""})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "bind",
|
||||
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
|
||||
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
|
||||
Hint: "pass --source openclaw|hermes, or run this command inside an OpenClaw or Hermes chat",
|
||||
})
|
||||
}
|
||||
|
||||
// clearAgentEnv removes every env var that DetectWorkspaceFromEnv treats as
|
||||
// an Agent signal, so tests exercising the "no signals" path stay isolated
|
||||
// from whatever the host shell exported. Prefix-based instead of an explicit
|
||||
// list — when DetectWorkspaceFromEnv gains a new OPENCLAW_* / HERMES_* signal,
|
||||
// this helper does not need to be updated and tests do not silently misroute.
|
||||
// t.Setenv restores the original values after the test returns.
|
||||
// clearAgentEnv removes all env vars that DetectWorkspaceFromEnv checks, so
|
||||
// tests exercising the "no signals" path are not affected by whatever the
|
||||
// host shell happens to have exported. t.Setenv restores them after the
|
||||
// test returns.
|
||||
func clearAgentEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, kv := range os.Environ() {
|
||||
idx := strings.IndexByte(kv, '=')
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
k := kv[:idx]
|
||||
if strings.HasPrefix(k, "OPENCLAW_") ||
|
||||
strings.HasPrefix(k, "HERMES_") ||
|
||||
k == "LARK_CHANNEL" {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
for _, k := range []string{
|
||||
"OPENCLAW_CLI", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH",
|
||||
"HERMES_HOME", "HERMES_QUIET", "HERMES_EXEC_ASK", "HERMES_GATEWAY_TOKEN", "HERMES_SESSION_KEY",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,8 +169,8 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "bind",
|
||||
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
|
||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||
})
|
||||
@@ -437,8 +185,8 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "bind",
|
||||
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
|
||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||
})
|
||||
@@ -566,8 +314,8 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
envPath := filepath.Join(hermesHome, ".env")
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "hermes",
|
||||
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
|
||||
Hint: "verify Hermes is installed and configured at " + envPath,
|
||||
})
|
||||
@@ -584,218 +332,13 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||
Hint: "verify OpenClaw is installed and configured",
|
||||
})
|
||||
}
|
||||
|
||||
// writeLarkChannelFixture writes a ~/.lark-channel/config.json under fakeHome
|
||||
// and returns the config path. resolveLarkChannelConfigPath reads HOME via
|
||||
// os.UserHomeDir, so callers must `t.Setenv("HOME", fakeHome)`.
|
||||
func writeLarkChannelFixture(t *testing.T, fakeHome, body string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(fakeHome, ".lark-channel")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// Happy-path: --source lark-channel reads ~/.lark-channel/config.json,
|
||||
// writes the workspace config, emits a JSON envelope with workspace:
|
||||
// "lark-channel" and brand from accounts.app.tenant.
|
||||
func TestConfigBindRun_LarkChannel_Success(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_lc_main","secret":"lc_secret","tenant":"feishu"}}}`)
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
|
||||
envelope := map[string]any{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v", err)
|
||||
}
|
||||
if envelope["workspace"] != "lark-channel" {
|
||||
t.Errorf("workspace = %v, want %q", envelope["workspace"], "lark-channel")
|
||||
}
|
||||
if envelope["app_id"] != "cli_lc_main" {
|
||||
t.Errorf("app_id = %v, want %q", envelope["app_id"], "cli_lc_main")
|
||||
}
|
||||
|
||||
// Brand is not in the stdout envelope — read it back from the persisted
|
||||
// workspace config to verify accounts.app.tenant flowed through to the
|
||||
// stored AppConfig.Brand field.
|
||||
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load workspace config: %v", err)
|
||||
}
|
||||
if len(multi.Apps) != 1 {
|
||||
t.Fatalf("expected 1 app, got %d", len(multi.Apps))
|
||||
}
|
||||
if got := string(multi.Apps[0].Brand); got != "feishu" {
|
||||
t.Errorf("Brand = %q, want %q", got, "feishu")
|
||||
}
|
||||
}
|
||||
|
||||
// Env template form: secret = "${VAR}" should resolve via the SecretInput
|
||||
// pipeline (same path openclaw uses), so the keychain receives the env value
|
||||
// not the literal template string.
|
||||
func TestConfigBindRun_LarkChannel_EnvTemplate(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("LARK_APP_SECRET", "resolved_via_env")
|
||||
writeLarkChannelFixture(t, fakeHome,
|
||||
`{"accounts":{"app":{"id":"cli_lc_env","secret":"${LARK_APP_SECRET}","tenant":"feishu"}}}`)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// tenant: "lark" should land as Brand("lark"), not normalized to "feishu".
|
||||
func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_lc_lark","secret":"s","tenant":"lark"}}}`)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load workspace config: %v", err)
|
||||
}
|
||||
if got := string(multi.Apps[0].Brand); got != "lark" {
|
||||
t.Errorf("Brand = %q, want %q (tenant: lark must flow through to AppConfig.Brand)", got, "lark")
|
||||
}
|
||||
}
|
||||
|
||||
// LARK_CHANNEL=1 alone (no --source) auto-detects to the lark-channel
|
||||
// workspace, mirroring the OpenClaw/Hermes auto-detect flow.
|
||||
func TestConfigBindRun_AutoDetect_LarkChannelFromEnv(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
t.Setenv("LARK_CHANNEL", "1")
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_auto_lc","secret":"s","tenant":"feishu"}}}`)
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
envelope := map[string]any{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v", err)
|
||||
}
|
||||
if envelope["workspace"] != "lark-channel" {
|
||||
t.Errorf("workspace = %v, want %q (auto-detection should pick lark-channel from LARK_CHANNEL=1)", envelope["workspace"], "lark-channel")
|
||||
}
|
||||
}
|
||||
|
||||
// --source lark-channel while the env signals OpenClaw must fail loud, same
|
||||
// rule as OpenClaw/Hermes mismatch (running in the wrong Agent context).
|
||||
func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
|
||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||
})
|
||||
}
|
||||
|
||||
// Missing config.json → typed error with a hint pointing at bridge setup.
|
||||
func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir() // empty — no .lark-channel/config.json
|
||||
t.Setenv("HOME", fakeHome)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||
Hint: "verify lark-channel-bridge is installed and configured",
|
||||
})
|
||||
}
|
||||
|
||||
// Empty accounts.app.id → typed error pointing at bridge setup. Distinct
|
||||
// from "missing file" so users know whether to install or to re-run setup.
|
||||
func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
configPath := writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"","secret":"","tenant":"feishu"}}}`)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "accounts.app.id missing in " + configPath,
|
||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||
})
|
||||
}
|
||||
|
||||
// app.id present but app.secret missing → typed error at the Build step.
|
||||
func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
configPath := writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_no_secret","secret":"","tenant":"feishu"}}}`)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
Message: "accounts.app.secret is empty in " + configPath,
|
||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigShowRun_WorkspaceField(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
configDir := t.TempDir()
|
||||
@@ -835,19 +378,15 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
||||
t.Fatal("expected error for unbound workspace")
|
||||
}
|
||||
// Should be a structured ConfigError suggesting config bind, not config init.
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
// Config errors share ExitAuth (3); the workspace is detected but no
|
||||
// binding exists yet, which is a config error.
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
}
|
||||
// The workspace name stays out of the wire subtype; it only appears in
|
||||
// the message.
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
|
||||
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
|
||||
@@ -1143,8 +682,12 @@ func TestConfigBindRun_OpenClawMultiAccount_MissingAppID(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for multi-account without --app-id, got nil")
|
||||
}
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1189,8 +732,8 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
||||
// iterates a map — ordering is non-deterministic. DeepEqual inline against
|
||||
// each accepted variant so every ErrDetail field (Type, Code, Message,
|
||||
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
|
||||
base := wantErrDetail{
|
||||
Type: "validation",
|
||||
base := output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||
}
|
||||
wantWorkFirst := base
|
||||
@@ -1198,17 +741,20 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
||||
wantPersonalFirst := base
|
||||
wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)"
|
||||
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; err = %v", err, err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
got := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil error detail")
|
||||
}
|
||||
if !reflect.DeepEqual(*exitErr.Detail, wantWorkFirst) &&
|
||||
!reflect.DeepEqual(*exitErr.Detail, wantPersonalFirst) {
|
||||
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
|
||||
got, wantWorkFirst, wantPersonalFirst)
|
||||
*exitErr.Detail, wantWorkFirst, wantPersonalFirst)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1232,8 +778,8 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only_one",
|
||||
})
|
||||
@@ -1252,7 +798,7 @@ func TestConfigBindRun_InvalidIdentity(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"})
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
|
||||
})
|
||||
@@ -1365,19 +911,11 @@ func TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce(t *testing.T) {
|
||||
Identity: "user-default",
|
||||
})
|
||||
msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default
|
||||
var ce *errs.ConfirmationRequiredError
|
||||
if !errors.As(err, &ce) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfirmationRequiredError; error = %v", err, err)
|
||||
}
|
||||
if ce.Risk != errs.RiskHighRiskWrite {
|
||||
t.Errorf("Risk = %q, want %q", ce.Risk, errs.RiskHighRiskWrite)
|
||||
}
|
||||
if ce.Message != msg.IdentityEscalationMessage {
|
||||
t.Errorf("Message mismatch:\ngot: %q\nwant: %q", ce.Message, msg.IdentityEscalationMessage)
|
||||
}
|
||||
if ce.Hint != msg.IdentityEscalationHint {
|
||||
t.Errorf("Hint mismatch:\ngot: %q\nwant: %q", ce.Hint, msg.IdentityEscalationHint)
|
||||
}
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "bind",
|
||||
Message: msg.IdentityEscalationMessage,
|
||||
Hint: msg.IdentityEscalationHint,
|
||||
})
|
||||
|
||||
// Config on disk must remain untouched — the gate runs before
|
||||
// commitBinding writes anything.
|
||||
@@ -1538,8 +1076,8 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
envPath := filepath.Join(hermesHome, ".env")
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "hermes",
|
||||
Message: "FEISHU_APP_ID not found in " + envPath,
|
||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||
})
|
||||
@@ -1558,8 +1096,8 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
envPath := filepath.Join(hermesHome, ".env")
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "hermes",
|
||||
Message: "FEISHU_APP_SECRET not found in " + envPath,
|
||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||
})
|
||||
@@ -1584,8 +1122,8 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "openclaw.json missing channels.feishu section",
|
||||
Hint: "configure Feishu in OpenClaw first",
|
||||
})
|
||||
@@ -1612,8 +1150,8 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
|
||||
openclawPath := filepath.Join(openclawDir, "openclaw.json")
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
|
||||
Hint: "configure channels.feishu.appSecret in openclaw.json",
|
||||
})
|
||||
@@ -1674,8 +1212,8 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "no Feishu app configured in openclaw.json",
|
||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||
})
|
||||
@@ -1706,14 +1244,10 @@ func TestGetBindMsg_En(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBindMsg_NonEnLang_FallsBackToZh(t *testing.T) {
|
||||
// Only zh and en TUI bundles exist; any non-English language (canonical
|
||||
// locale, short code, or unrecognized value) falls back to zh.
|
||||
for _, lang := range []i18n.Lang{"fr_fr", "ja_jp", "ko", "unknown", ""} {
|
||||
msg := getBindMsg(lang)
|
||||
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
|
||||
t.Errorf("getBindMsg(%q) SelectSource = %q, want %q (zh fallback)", lang, msg.SelectSource, want)
|
||||
}
|
||||
func TestGetBindMsg_UnknownLang_DefaultsToZh(t *testing.T) {
|
||||
msg := getBindMsg("fr")
|
||||
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
|
||||
t.Errorf("fr (default) SelectSource = %q, want %q", msg.SelectSource, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1876,36 +1410,3 @@ func TestHasStrictBotLock(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigBindRun_LangExplicit_PrintsConfirmation covers the flag-mode
|
||||
// confirmation line: when --lang is explicit, bind prints "language preference
|
||||
// set" to stderr (rendered in the TUI language, embedding the preference value).
|
||||
func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
hermesHome := t.TempDir()
|
||||
t.Setenv("HERMES_HOME", hermesHome)
|
||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
||||
t.Fatalf("write .env: %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{
|
||||
Factory: f,
|
||||
Source: "hermes",
|
||||
Identity: "bot-only",
|
||||
Lang: "en",
|
||||
langExplicit: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
// The short --lang en is canonicalized to en_us before the confirmation
|
||||
// echoes it back; the TUI language stays zh (flag mode, no picker).
|
||||
want := fmt.Sprintf(getBindMsg(i18n.LangZhCN).LangPreferenceSet, "en_us")
|
||||
if got := stderr.String(); !strings.Contains(got, want) {
|
||||
t.Errorf("stderr = %q, want it to contain confirmation %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/binding"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -46,10 +46,8 @@ func newBinder(source string, opts *BindOptions) (SourceBinder, error) {
|
||||
return &openclawBinder{opts: opts, path: resolveOpenClawConfigPath()}, nil
|
||||
case "hermes":
|
||||
return &hermesBinder{opts: opts, path: resolveHermesEnvPath()}, nil
|
||||
case "lark-channel":
|
||||
return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil
|
||||
default:
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported source: %s", source).WithParam("--source")
|
||||
return nil, output.ErrValidation("unsupported source: %s", source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,10 +83,11 @@ func selectCandidate(
|
||||
// from ListCandidates itself and never reach here.
|
||||
switch src {
|
||||
case "openclaw":
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "no Feishu app configured in openclaw.json").
|
||||
WithHint("configure channels.feishu.appId in openclaw.json")
|
||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||
"no Feishu app configured in openclaw.json",
|
||||
"configure channels.feishu.appId in openclaw.json")
|
||||
default:
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "%s: no app configured", src)
|
||||
return nil, output.ErrValidation("%s: no app configured", src)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +97,9 @@ func selectCandidate(
|
||||
return &candidates[i], nil
|
||||
}
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id %q not found in %s", appIDFlag, cfgBase).
|
||||
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
|
||||
WithParam("--app-id")
|
||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||
fmt.Sprintf("--app-id %q not found in %s", appIDFlag, cfgBase),
|
||||
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
||||
}
|
||||
|
||||
if len(candidates) == 1 {
|
||||
@@ -111,9 +110,9 @@ func selectCandidate(
|
||||
return tuiPrompt(candidates)
|
||||
}
|
||||
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "multiple accounts in %s; pass --app-id <id>", cfgBase).
|
||||
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
|
||||
WithParam("--app-id")
|
||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||
fmt.Sprintf("multiple accounts in %s; pass --app-id <id>", cfgBase),
|
||||
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
||||
}
|
||||
|
||||
// formatCandidates renders candidates as "AppID (Label)" lines for error hints.
|
||||
@@ -148,13 +147,14 @@ func (b *openclawBinder) ConfigPath() string { return b.path }
|
||||
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||
cfg, err := binding.ReadOpenClawConfig(b.path)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
||||
WithHint("verify OpenClaw is installed and configured").
|
||||
WithCause(err)
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
fmt.Sprintf("cannot read %s: %v", b.path, err),
|
||||
"verify OpenClaw is installed and configured")
|
||||
}
|
||||
if cfg.Channels.Feishu == nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "openclaw.json missing channels.feishu section").
|
||||
WithHint("configure Feishu in OpenClaw first")
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
"openclaw.json missing channels.feishu section",
|
||||
"configure Feishu in OpenClaw first")
|
||||
}
|
||||
|
||||
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
|
||||
@@ -170,7 +170,8 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||
|
||||
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.cfg == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||
"internal: Build called before ListCandidates")
|
||||
}
|
||||
|
||||
var selected *binding.CandidateApp
|
||||
@@ -181,25 +182,26 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
|
||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||
"internal: appID %q not in candidates", appID)
|
||||
}
|
||||
|
||||
if selected.AppSecret.IsZero() {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path).
|
||||
WithHint("configure channels.feishu.appSecret in openclaw.json")
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
fmt.Sprintf("appSecret is empty for app %s in %s", selected.AppID, b.path),
|
||||
"configure channels.feishu.appSecret in openclaw.json")
|
||||
}
|
||||
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err).
|
||||
WithHint("check appSecret configuration in %s", b.path).
|
||||
WithCause(err)
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
fmt.Sprintf("failed to resolve appSecret for %s: %v", selected.AppID, err),
|
||||
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||
WithHint("use file: reference in config to bypass keychain").
|
||||
WithCause(err)
|
||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
@@ -225,14 +227,15 @@ func (b *hermesBinder) ConfigPath() string { return b.path }
|
||||
func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
||||
envMap, err := readDotenv(b.path)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to read Hermes config: %v", err).
|
||||
WithHint("verify Hermes is installed and configured at %s", b.path).
|
||||
WithCause(err)
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||
fmt.Sprintf("failed to read Hermes config: %v", err),
|
||||
fmt.Sprintf("verify Hermes is installed and configured at %s", b.path))
|
||||
}
|
||||
appID := envMap["FEISHU_APP_ID"]
|
||||
if appID == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "FEISHU_APP_ID not found in %s", b.path).
|
||||
WithHint("run 'hermes setup' to configure Feishu credentials")
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||
fmt.Sprintf("FEISHU_APP_ID not found in %s", b.path),
|
||||
"run 'hermes setup' to configure Feishu credentials")
|
||||
}
|
||||
b.envMap = envMap
|
||||
return []Candidate{{AppID: appID, Label: "default"}}, nil
|
||||
@@ -240,22 +243,24 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
||||
|
||||
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.envMap == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||
"internal: Build called before ListCandidates")
|
||||
}
|
||||
if b.envMap["FEISHU_APP_ID"] != appID {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match env", appID)
|
||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||
"internal: appID %q does not match env", appID)
|
||||
}
|
||||
appSecret := b.envMap["FEISHU_APP_SECRET"]
|
||||
if appSecret == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "FEISHU_APP_SECRET not found in %s", b.path).
|
||||
WithHint("run 'hermes setup' to configure Feishu credentials")
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||
fmt.Sprintf("FEISHU_APP_SECRET not found in %s", b.path),
|
||||
"run 'hermes setup' to configure Feishu credentials")
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||
WithHint("use file: reference in config to bypass keychain").
|
||||
WithCause(err)
|
||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
@@ -265,71 +270,6 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// larkChannelBinder
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
type larkChannelBinder struct {
|
||||
opts *BindOptions
|
||||
path string
|
||||
|
||||
// Cached between ListCandidates and Build so we don't re-read the file.
|
||||
cfg *binding.LarkChannelRoot
|
||||
}
|
||||
|
||||
func (b *larkChannelBinder) Name() string { return "lark-channel" }
|
||||
func (b *larkChannelBinder) ConfigPath() string { return b.path }
|
||||
|
||||
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
||||
cfg, err := binding.ReadLarkChannelConfig(b.path)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
||||
WithHint("verify lark-channel-bridge is installed and configured").
|
||||
WithCause(err)
|
||||
}
|
||||
if cfg.Accounts.App.ID == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "accounts.app.id missing in %s", b.path).
|
||||
WithHint("run lark-channel-bridge's setup to populate the app credential")
|
||||
}
|
||||
b.cfg = cfg
|
||||
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
|
||||
}
|
||||
|
||||
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.cfg == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||
}
|
||||
if b.cfg.Accounts.App.ID != appID {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match config", appID)
|
||||
}
|
||||
if b.cfg.Accounts.App.Secret.IsZero() {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "accounts.app.secret is empty in %s", b.path).
|
||||
WithHint("run lark-channel-bridge's setup to populate the app credential")
|
||||
}
|
||||
|
||||
// Resolve through the same SecretInput pipeline openclaw uses, so
|
||||
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
|
||||
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err).
|
||||
WithHint("check appSecret configuration in %s", b.path).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||
WithHint("use file: reference in config to bypass keychain").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Source-specific helpers (path / dotenv / brand) — kept private to this package.
|
||||
// Moved here from bind.go so bind.go can focus on orchestration.
|
||||
@@ -343,8 +283,6 @@ func sourceDisplayName(source string) string {
|
||||
return "OpenClaw"
|
||||
case "hermes":
|
||||
return "Hermes"
|
||||
case "lark-channel":
|
||||
return "Lark Channel"
|
||||
default:
|
||||
return source
|
||||
}
|
||||
@@ -378,20 +316,6 @@ func resolveHermesEnvPath() string {
|
||||
return filepath.Join(hermesHome, ".env")
|
||||
}
|
||||
|
||||
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
|
||||
// source config. LARK_CHANNEL_CONFIG lets a host point bind at a projected
|
||||
// single-account config without changing lark-cli's target config directory.
|
||||
func resolveLarkChannelConfigPath() string {
|
||||
if p := os.Getenv("LARK_CHANNEL_CONFIG"); strings.TrimSpace(p) != "" {
|
||||
return expandHome(p)
|
||||
}
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
}
|
||||
return filepath.Join(home, ".lark-channel", "config.json")
|
||||
}
|
||||
|
||||
// resolveOpenClawConfigPath resolves openclaw.json path using the same priority
|
||||
// chain as OpenClaw's src/config/paths.ts:
|
||||
// 1. OPENCLAW_CONFIG_PATH env → exact file path
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
@@ -51,8 +50,8 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
|
||||
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "no Feishu app configured in openclaw.json",
|
||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||
})
|
||||
@@ -64,8 +63,8 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
|
||||
// even before it has a bespoke error message.
|
||||
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
|
||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
Type: "config",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: "hermes: no app configured",
|
||||
})
|
||||
}
|
||||
@@ -100,8 +99,8 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||
})
|
||||
@@ -117,8 +116,8 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||
})
|
||||
@@ -152,8 +151,8 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{{AppID: "cli_only"}}
|
||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, wantErrDetail{
|
||||
Type: "validation",
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only",
|
||||
})
|
||||
@@ -174,27 +173,3 @@ func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
|
||||
}
|
||||
assertCandidate(t, got, Candidate{AppID: "cli_b"})
|
||||
}
|
||||
|
||||
func TestResolveLarkChannelConfigPath_Default(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("LARK_CHANNEL_CONFIG", "")
|
||||
|
||||
got := resolveLarkChannelConfigPath()
|
||||
want := filepath.Join(home, ".lark-channel", "config.json")
|
||||
if got != want {
|
||||
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLarkChannelConfigPath_EnvOverride(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("LARK_CHANNEL_CONFIG", "~/bridge/projection.json")
|
||||
|
||||
got := resolveLarkChannelConfigPath()
|
||||
want := filepath.Join(home, "bridge", "projection.json")
|
||||
if got != want {
|
||||
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -40,7 +38,6 @@ func (r *recordingConfigKeychain) Remove(service, account string) error {
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_FlagParsing(t *testing.T) {
|
||||
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret123\n")
|
||||
|
||||
@@ -93,16 +90,15 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
// Config errors share ExitAuth (3), not ExitValidation.
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr)
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,16 +123,19 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no active profile") {
|
||||
t.Fatalf("error = %v, want to contain 'no active profile'", err)
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "no active profile" {
|
||||
t.Fatalf("detail = %#v, want config/no active profile", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_LangFlag(t *testing.T) {
|
||||
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ConfigInitOptions
|
||||
@@ -149,9 +148,8 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// --lang en is canonicalized to en_us in RunE before runF captures opts.
|
||||
if gotOpts.Lang != string(i18n.LangEnUS) {
|
||||
t.Errorf("expected Lang en_us, got %s", gotOpts.Lang)
|
||||
if gotOpts.Lang != "en" {
|
||||
t.Errorf("expected Lang en, got %s", gotOpts.Lang)
|
||||
}
|
||||
if !gotOpts.langExplicit {
|
||||
t.Error("expected langExplicit=true when --lang is passed")
|
||||
@@ -159,7 +157,6 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_LangDefault(t *testing.T) {
|
||||
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ConfigInitOptions
|
||||
@@ -172,88 +169,14 @@ func TestConfigInitCmd_LangDefault(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.Lang != "" {
|
||||
t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang)
|
||||
if gotOpts.Lang != "zh" {
|
||||
t.Errorf("expected default Lang zh, got %s", gotOpts.Lang)
|
||||
}
|
||||
if gotOpts.langExplicit {
|
||||
t.Error("expected langExplicit=false when --lang is not passed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveInitConfig_OmitLangPreservesPrior guards the single-app replace path:
|
||||
// re-running init without --lang must inherit the prior preference, not clear it.
|
||||
func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
existing := &core.MultiAppConfig{Apps: []core.AppConfig{
|
||||
{AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP},
|
||||
}}
|
||||
if err := core.SaveMultiAppConfig(existing); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
|
||||
t.Fatalf("saveInitConfig (no --lang): %v", err)
|
||||
}
|
||||
|
||||
got, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
if app := got.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
|
||||
t.Errorf("Lang after re-init = %v, want %q (preserved)", app, i18n.LangJaJP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
|
||||
// strictly validated the same way bind validates: wrong-case / typo / removed
|
||||
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
|
||||
func TestConfigInitCmd_InvalidLang(t *testing.T) {
|
||||
clearAgentEnv(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
lang string
|
||||
}{
|
||||
{"wrong case ZH", "ZH"},
|
||||
{"typo frr", "frr"},
|
||||
{"removed code ar", "ar"},
|
||||
{"unknown xx", "xx"},
|
||||
{"hyphen form zh-CN", "zh-CN"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdConfigInit(f, nil)
|
||||
f.IOStreams.In = strings.NewReader("sec\n")
|
||||
cmd.SetArgs([]string{"--lang", tc.lang, "--app-id", "x", "--app-secret-stdin"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if valErr.Param != "--lang" {
|
||||
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid --lang") {
|
||||
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAnyNonInteractiveFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -392,38 +315,8 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
// A name/appId conflict is user input — a typed validation error naming the
|
||||
// offending flag, not a system storage failure.
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
}
|
||||
if verr.Param != "--name" {
|
||||
t.Errorf("param = %q, want --name", verr.Param)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", output.ExitCodeOf(err), output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "conflicts with existing appId") {
|
||||
t.Errorf("message = %q, want conflict description", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapSaveConfigError_PassesTypedValidationThrough pins that a user-input
|
||||
// validation error (e.g. the --name conflict) is not reclassified as an
|
||||
// internal storage failure on its way up through the save call sites.
|
||||
func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
|
||||
conflict := errs.NewValidationError(errs.SubtypeInvalidArgument, "name conflict").WithParam("--name")
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(wrapSaveConfigError(conflict), &verr) {
|
||||
t.Fatalf("typed validation must pass through unchanged, got %T", wrapSaveConfigError(conflict))
|
||||
}
|
||||
var ierr *errs.InternalError
|
||||
if !errors.As(wrapSaveConfigError(errors.New("disk full")), &ierr) || ierr.Subtype != errs.SubtypeStorage {
|
||||
t.Fatalf("untyped failure must become internal/storage")
|
||||
if !strings.Contains(err.Error(), "conflicts with existing appId") {
|
||||
t.Fatalf("error = %v, want conflict with existing appId", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,65 +395,16 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||
}
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
|
||||
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
|
||||
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
|
||||
// the same locale; an unrecognized value errors.
|
||||
func TestValidateInitLang(t *testing.T) {
|
||||
t.Run("empty is a no-op", func(t *testing.T) {
|
||||
for _, explicit := range []bool{false, true} {
|
||||
opts := &ConfigInitOptions{Lang: "", langExplicit: explicit}
|
||||
if err := validateInitLang(opts); err != nil {
|
||||
t.Fatalf("explicit=%v: expected nil error, got %v", explicit, err)
|
||||
}
|
||||
if opts.Lang != "" {
|
||||
t.Errorf("explicit=%v: Lang = %q, want \"\" (unset)", explicit, opts.Lang)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("short and locale canonicalize alike", func(t *testing.T) {
|
||||
for _, in := range []string{"ja", "ja_jp"} {
|
||||
opts := &ConfigInitOptions{Lang: in, langExplicit: true}
|
||||
if err := validateInitLang(opts); err != nil {
|
||||
t.Fatalf("--lang %q: unexpected error %v", in, err)
|
||||
}
|
||||
if opts.Lang != string(i18n.LangJaJP) {
|
||||
t.Errorf("--lang %q normalized to %q, want %q", in, opts.Lang, i18n.LangJaJP)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints
|
||||
// to stderr only when --lang explicitly set a non-empty preference.
|
||||
func TestPrintLangPreferenceConfirmation(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Run("explicit non-empty prints confirmation", func(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: true})
|
||||
got := stderr.String()
|
||||
if !strings.Contains(got, "语言偏好") || !strings.Contains(got, "en_us") {
|
||||
t.Errorf("stderr = %q, want confirmation mentioning the preference and en_us", got)
|
||||
}
|
||||
})
|
||||
t.Run("implicit prints nothing", func(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: false})
|
||||
if got := stderr.String(); got != "" {
|
||||
t.Errorf("stderr = %q, want empty when --lang is implicit", got)
|
||||
}
|
||||
})
|
||||
t.Run("explicit empty prints nothing", func(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "", UILang: i18n.LangZhCN, langExplicit: true})
|
||||
if got := stderr.String(); got != "" {
|
||||
t.Errorf("stderr = %q, want empty when --lang is empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -41,17 +41,16 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
value := args[0]
|
||||
if value != "user" && value != "bot" && value != "auto" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value)
|
||||
return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value)
|
||||
}
|
||||
|
||||
app.DefaultAs = core.Identity(value)
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -6,18 +6,18 @@ package config
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -31,13 +31,9 @@ type ConfigInitOptions struct {
|
||||
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
|
||||
Brand string
|
||||
New bool
|
||||
|
||||
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
|
||||
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
|
||||
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
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
|
||||
@@ -49,7 +45,7 @@ type ConfigInitOptions struct {
|
||||
|
||||
// NewCmdConfigInit creates the config init subcommand.
|
||||
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
|
||||
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN}
|
||||
opts := &ConfigInitOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
@@ -67,9 +63,6 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
if err := validateInitLang(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := guardAgentWorkspace(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -84,33 +77,13 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
|
||||
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
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")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// printLangPreferenceConfirmation echoes the set preference to stderr, only
|
||||
// when --lang explicitly set a non-empty value.
|
||||
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
|
||||
if !opts.langExplicit || opts.Lang == "" {
|
||||
return
|
||||
}
|
||||
msg := getInitMsg(opts.UILang)
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, opts.Lang))
|
||||
}
|
||||
|
||||
func validateInitLang(opts *ConfigInitOptions) error {
|
||||
lang, err := cmdutil.ParseLangFlag(opts.Lang)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Lang = string(lang)
|
||||
return nil
|
||||
}
|
||||
|
||||
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
|
||||
// Hermes Agent context, because the Agent has already provisioned an app
|
||||
// and 'config bind' is the right tool for hooking lark-cli into it.
|
||||
@@ -125,9 +98,12 @@ 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 &core.ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
|
||||
Hint: "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.",
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
@@ -155,7 +131,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
|
||||
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
config := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
|
||||
AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{},
|
||||
}},
|
||||
}
|
||||
return core.SaveMultiAppConfig(config)
|
||||
@@ -169,27 +145,7 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
|
||||
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
||||
}
|
||||
cleanupOldConfig(existing, f, appId)
|
||||
var prior i18n.Lang
|
||||
if existing != nil {
|
||||
if app := existing.CurrentAppConfig(""); app != nil {
|
||||
prior = app.Lang
|
||||
}
|
||||
}
|
||||
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
|
||||
}
|
||||
|
||||
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
|
||||
// validation error from saveAsProfile) through unchanged, and classifies any
|
||||
// other failure as an internal storage error. Without the passthrough a user
|
||||
// input error would surface to agents as a system storage failure.
|
||||
func wrapSaveConfigError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return saveAsOnlyApp(appId, secret, brand, lang)
|
||||
}
|
||||
|
||||
// saveAsProfile appends or updates a named profile in the config.
|
||||
@@ -210,15 +166,14 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
||||
}
|
||||
multi.Apps[idx].Users = []core.AppUser{}
|
||||
}
|
||||
// Update existing profile
|
||||
multi.Apps[idx].AppId = appId
|
||||
multi.Apps[idx].AppSecret = secret
|
||||
multi.Apps[idx].Brand = brand
|
||||
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
|
||||
multi.Apps[idx].Lang = lang
|
||||
} else {
|
||||
if findAppIndexByAppID(multi, profileName) >= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"profile name %q conflicts with existing appId", profileName).
|
||||
WithParam("--name")
|
||||
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
|
||||
}
|
||||
// Append new profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
@@ -226,7 +181,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
||||
AppId: appId,
|
||||
AppSecret: secret,
|
||||
Brand: brand,
|
||||
Lang: i18n.Lang(lang),
|
||||
Lang: lang,
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
}
|
||||
@@ -257,25 +212,9 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// wrapUpdateExistingProfileErr classifies the error returned by
|
||||
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
|
||||
// for blank-input) pass through unchanged so their exit code semantics
|
||||
// survive; everything else (filesystem, keychain, etc.) is wrapped as
|
||||
// InternalError.
|
||||
func wrapUpdateExistingProfileErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
|
||||
if existing == nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
|
||||
WithParam("--app-secret")
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
}
|
||||
|
||||
var app *core.AppConfig
|
||||
@@ -283,25 +222,22 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
|
||||
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
||||
app = &existing.Apps[idx]
|
||||
} else {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile").
|
||||
WithParam("--app-secret")
|
||||
return output.ErrValidation("App Secret cannot be empty for new profile")
|
||||
}
|
||||
} else {
|
||||
app = existing.CurrentAppConfig("")
|
||||
if app == nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
|
||||
WithParam("--app-secret")
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
}
|
||||
}
|
||||
|
||||
if app.AppId != appID {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty when changing App ID").
|
||||
WithParam("--app-secret")
|
||||
return output.ErrValidation("App Secret cannot be empty when changing App ID")
|
||||
}
|
||||
|
||||
app.AppId = appID
|
||||
app.Brand = brand
|
||||
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
|
||||
app.Lang = lang
|
||||
return core.SaveMultiAppConfig(existing)
|
||||
}
|
||||
|
||||
@@ -313,13 +249,13 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to read secret from stdin: %v", err).WithCause(err)
|
||||
return output.ErrValidation("failed to read secret from stdin: %v", err)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret")
|
||||
return output.ErrValidation("stdin is empty, expected app secret")
|
||||
}
|
||||
opts.appSecret = strings.TrimSpace(scanner.Text())
|
||||
if opts.appSecret == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
|
||||
return output.ErrValidation("app secret read from stdin is empty")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +267,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
// Validate --profile name if set
|
||||
if opts.ProfileName != "" {
|
||||
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,33 +276,35 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
brand := parseBrand(opts.Brand)
|
||||
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
||||
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For interactive modes, prompt language selection if --lang was not explicitly set.
|
||||
// Picker offers 2 options (中文 / English) and drives BOTH opts.Lang
|
||||
// (preference) and opts.UILang (TUI rendering).
|
||||
// For interactive modes, prompt language selection if --lang was not explicitly set
|
||||
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
||||
lang, err := promptLangSelection()
|
||||
if err != nil {
|
||||
return langSelectionError(err)
|
||||
savedLang := ""
|
||||
if existing != nil {
|
||||
if app := existing.CurrentAppConfig(""); app != nil {
|
||||
savedLang = app.Lang
|
||||
}
|
||||
}
|
||||
opts.Lang = string(lang)
|
||||
opts.UILang = lang
|
||||
lang, err := promptLangSelection(savedLang)
|
||||
if err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
return err
|
||||
}
|
||||
opts.Lang = lang
|
||||
}
|
||||
|
||||
msg := getInitMsg(opts.UILang)
|
||||
msg := getInitMsg(opts.Lang)
|
||||
|
||||
// Mode 3: Create new app directly (--new)
|
||||
if opts.New {
|
||||
@@ -375,21 +313,17 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return err
|
||||
}
|
||||
if result == nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
|
||||
return output.ErrValidation("app creation returned no result")
|
||||
}
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -400,8 +334,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return err
|
||||
}
|
||||
if result == nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
||||
WithParam("--app-id")
|
||||
return output.ErrValidation("App ID and App Secret cannot be empty")
|
||||
}
|
||||
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
@@ -410,36 +343,33 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
// New secret provided (either from "create" or "existing" with input)
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
// Existing app with unchanged secret — update app ID and brand only
|
||||
if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil {
|
||||
return err
|
||||
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
} else {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
||||
WithParam("--app-id")
|
||||
return output.ErrValidation("App ID and App Secret cannot be empty")
|
||||
}
|
||||
|
||||
if result.Mode == "existing" {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if result.AppSecret != "" {
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-terminal: cannot run interactive mode, guide user to --new
|
||||
if !f.IOStreams.IsTerminal {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
|
||||
return output.ErrValidation("config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
|
||||
}
|
||||
|
||||
// Mode 5: Legacy interactive (readline fallback)
|
||||
@@ -467,7 +397,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
appIdInput, err := readLine(prompt)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
prompt = "App Secret"
|
||||
@@ -476,7 +406,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
appSecretInput, err := readLine(prompt)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
prompt = "Brand (lark/feishu)"
|
||||
@@ -487,7 +417,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
brandInput, err := readLine(prompt)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
resolvedAppId := appIdInput
|
||||
@@ -509,23 +439,16 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
|
||||
if resolvedAppId == "" || resolvedSecret.IsZero() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
||||
WithParam("--app-id")
|
||||
return output.ErrValidation("App ID and App Secret cannot be empty")
|
||||
}
|
||||
|
||||
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if appSecretInput != "" {
|
||||
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
||||
clearAgentEnv(t)
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
t.Setenv("OPENCLAW_CLI", "")
|
||||
t.Setenv("HERMES_HOME", "")
|
||||
|
||||
if err := guardAgentWorkspace(&ConfigInitOptions{}); err != nil {
|
||||
t.Errorf("local workspace should allow init, got: %v", err)
|
||||
@@ -26,15 +28,12 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in OpenClaw context, got nil")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "openclaw") {
|
||||
t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message)
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
|
||||
@@ -51,15 +50,12 @@ func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in Hermes context, got nil")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "hermes") {
|
||||
t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message)
|
||||
if cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,16 @@ package config
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// configInitResult holds the result of the interactive config init flow.
|
||||
@@ -126,16 +125,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
||||
}, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case appID == "" && appSecret == "":
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
||||
WithParam("--app-id")
|
||||
case appID == "":
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID cannot be empty").
|
||||
WithParam("--app-id")
|
||||
case appSecret == "":
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty").
|
||||
WithParam("--app-secret")
|
||||
if appID == "" || appSecret == "" {
|
||||
return nil, output.ErrValidation("App ID and App Secret cannot be empty")
|
||||
}
|
||||
|
||||
return &configInitResult{
|
||||
@@ -177,12 +168,10 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
}
|
||||
|
||||
// Step 1: Request app registration (begin)
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
httpClient := &http.Client{}
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||
return nil, output.ErrAuth("app registration failed: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Build and display verification URL + QR code
|
||||
@@ -210,7 +199,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
}
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
|
||||
return nil, output.ErrAuth("%v", err)
|
||||
}
|
||||
|
||||
// Step 4: Handle Lark brand special case
|
||||
@@ -219,12 +208,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
|
||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
|
||||
return nil, output.ErrAuth("lark endpoint retry failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if result.ClientID == "" || result.ClientSecret == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
|
||||
return nil, output.ErrAuth("app registration succeeded but missing client_id or client_secret")
|
||||
}
|
||||
|
||||
// Determine final brand from response
|
||||
|
||||
@@ -4,14 +4,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type initMsg struct {
|
||||
@@ -31,10 +26,6 @@ type initMsg struct {
|
||||
DetectedLarkTenant string
|
||||
AppCreated string
|
||||
ConfigSaved string
|
||||
|
||||
// LangPreferenceSet is printed to stderr after a successful init when the
|
||||
// user explicitly passed --lang. Format: language code.
|
||||
LangPreferenceSet string
|
||||
}
|
||||
|
||||
var initMsgZh = &initMsg{
|
||||
@@ -52,7 +43,6 @@ var initMsgZh = &initMsg{
|
||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||
AppCreated: "应用配置成功! App ID: %s",
|
||||
ConfigSaved: "应用配置成功! App ID: %s",
|
||||
LangPreferenceSet: "语言偏好已设置:%s",
|
||||
}
|
||||
|
||||
var initMsgEn = &initMsg{
|
||||
@@ -70,27 +60,29 @@ var initMsgEn = &initMsg{
|
||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||
AppCreated: "App configured! App ID: %s",
|
||||
ConfigSaved: "App configured! App ID: %s",
|
||||
LangPreferenceSet: "Language preference set to: %s",
|
||||
}
|
||||
|
||||
// getInitMsg picks the zh/en TUI bundle; non-English falls back to zh.
|
||||
func getInitMsg(lang i18n.Lang) *initMsg {
|
||||
if lang.IsEnglish() {
|
||||
func getInitMsg(lang string) *initMsg {
|
||||
if lang == "en" {
|
||||
return initMsgEn
|
||||
}
|
||||
return initMsgZh
|
||||
}
|
||||
|
||||
// promptLangSelection shows the 中文/English picker and returns the chosen locale.
|
||||
func promptLangSelection() (i18n.Lang, error) {
|
||||
lang := i18n.LangZhCN
|
||||
// promptLangSelection shows an interactive language picker and returns the chosen lang code.
|
||||
// savedLang is used as the pre-selected default (from existing config).
|
||||
func promptLangSelection(savedLang string) (string, error) {
|
||||
lang := savedLang
|
||||
if lang != "en" {
|
||||
lang = "zh"
|
||||
}
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[i18n.Lang]().
|
||||
huh.NewSelect[string]().
|
||||
Title("Language / 语言").
|
||||
Options(
|
||||
huh.NewOption("中文", i18n.LangZhCN),
|
||||
huh.NewOption("English", i18n.LangEnUS),
|
||||
huh.NewOption("中文", "zh"),
|
||||
huh.NewOption("English", "en"),
|
||||
).
|
||||
Value(&lang),
|
||||
),
|
||||
@@ -101,12 +93,3 @@ func promptLangSelection() (i18n.Lang, error) {
|
||||
}
|
||||
return lang, nil
|
||||
}
|
||||
|
||||
// langSelectionError maps a promptLangSelection failure to its exit surface:
|
||||
// user abort exits bare with code 1; any other failure is internal.
|
||||
func langSelectionError(err error) error {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "language selection failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
)
|
||||
|
||||
func TestGetInitMsg_Zh(t *testing.T) {
|
||||
@@ -31,7 +29,7 @@ func TestGetInitMsg_En(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetInitMsg_DefaultsToZh(t *testing.T) {
|
||||
for _, lang := range []i18n.Lang{"", "unknown", "xyz", "invalid"} {
|
||||
for _, lang := range []string{"", "fr", "ja", "unknown"} {
|
||||
msg := getInitMsg(lang)
|
||||
if msg != initMsgZh {
|
||||
t.Errorf("getInitMsg(%q) should default to zh", lang)
|
||||
@@ -64,7 +62,6 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||
"AppCreated": msg.AppCreated,
|
||||
"ConfigSaved": msg.ConfigSaved,
|
||||
"LangPreferenceSet": msg.LangPreferenceSet,
|
||||
}
|
||||
for name, val := range fields {
|
||||
if val == "" {
|
||||
@@ -74,7 +71,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
||||
}
|
||||
|
||||
func TestInitMsg_FormatStrings(t *testing.T) {
|
||||
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
msg := getInitMsg(lang)
|
||||
// AppCreated and ConfigSaved should contain %s for App ID
|
||||
got := fmt.Sprintf(msg.AppCreated, "cli_test123")
|
||||
@@ -87,37 +84,3 @@ func TestInitMsg_FormatStrings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInitMsg_BilingualCollapse(t *testing.T) {
|
||||
// The TUI is bilingual (zh + en). Only English-bucket languages return the
|
||||
// English struct — by canonical locale ("en_us") or legacy short ("en").
|
||||
// Everything else (zh, the other codes, invalid, "") returns Chinese.
|
||||
tests := []struct {
|
||||
lang i18n.Lang
|
||||
shouldBeEn bool
|
||||
}{
|
||||
{i18n.LangZhCN, false},
|
||||
{i18n.LangEnUS, true},
|
||||
{"en", true}, // legacy short value
|
||||
{i18n.LangJaJP, false},
|
||||
{"fr_fr", false},
|
||||
{"invalid", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.lang), func(t *testing.T) {
|
||||
msg := getInitMsg(tt.lang)
|
||||
if msg == nil {
|
||||
t.Fatal("getInitMsg returned nil")
|
||||
}
|
||||
want := initMsgZh
|
||||
if tt.shouldBeEn {
|
||||
want = initMsgEn
|
||||
}
|
||||
if msg != want {
|
||||
t.Errorf("getInitMsg(%q) returned wrong struct", tt.lang)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// probeTimeout is the total wall-clock budget for the credential probe step
|
||||
// (covering both TAT acquisition and the subsequent probe request).
|
||||
const probeTimeout = 3 * time.Second
|
||||
|
||||
// runProbe runs a best-effort credential validation after config init has
|
||||
// persisted the App ID and App Secret. It returns a non-nil error only for a
|
||||
// deterministic credential-rejection signal; every other outcome returns nil
|
||||
// so that valid configurations and transient/upstream noise never block the
|
||||
// command.
|
||||
//
|
||||
// The function performs up to two HTTP calls in series, bounded by
|
||||
// probeTimeout:
|
||||
//
|
||||
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||
// only when the unified Token Endpoint deterministically rejected the
|
||||
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
|
||||
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
|
||||
// typed error is propagated so the root dispatcher renders the canonical
|
||||
// envelope and `config init` exits non-zero — identical to how every other
|
||||
// token-resolving command reports the same bad credentials. Ambiguous
|
||||
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
|
||||
// timeouts) come back as raw untyped errors and are swallowed (return nil),
|
||||
// so valid configurations are never disturbed by upstream noise.
|
||||
// errs.IsTyped is the discriminator.
|
||||
//
|
||||
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||
// that call (success, server error, timeout, parse failure) is always
|
||||
// ignored — return nil regardless.
|
||||
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
|
||||
if factory == nil {
|
||||
return nil
|
||||
}
|
||||
httpClient, err := factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
|
||||
if err != nil {
|
||||
// A typed error from FetchTAT is a deterministic credential rejection
|
||||
// (classifyTATResponseCode). Propagate it so config init exits with the
|
||||
// same envelope the rest of the CLI uses for bad credentials. Untyped
|
||||
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
|
||||
// silent and let the command succeed.
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TAT succeeded — fire the probe call. Any outcome is ignored.
|
||||
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
|
||||
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// fakeRT routes requests to per-path handlers and records what it saw.
|
||||
type fakeRT struct {
|
||||
tatHandler func(req *http.Request) (*http.Response, error)
|
||||
probeHandler func(req *http.Request) (*http.Response, error)
|
||||
tatCalls int
|
||||
probeCalls int
|
||||
probeReq *http.Request
|
||||
probeBody string
|
||||
}
|
||||
|
||||
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
|
||||
f.tatCalls++
|
||||
if f.tatHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
|
||||
}
|
||||
return f.tatHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||
f.probeCalls++
|
||||
f.probeReq = req
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
f.probeBody = string(b)
|
||||
}
|
||||
if f.probeHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
|
||||
}
|
||||
return f.probeHandler(req)
|
||||
}
|
||||
return nil, errors.New("unexpected URL: " + req.URL.String())
|
||||
}
|
||||
|
||||
func jsonResp(code int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: code,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
// fakeFactory builds a test Factory whose HttpClient is overridden to use
|
||||
// the caller-supplied RoundTripper.
|
||||
//
|
||||
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
|
||||
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
|
||||
// guidance). The HttpClient is then swapped to our stub so we can drive
|
||||
// exact HTTP responses for the probe. Config-dir isolation is set up via
|
||||
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
|
||||
// touch lands in a temp dir rather than the developer's real config.
|
||||
//
|
||||
// The returned buffer is the Factory's stderr. runProbe never writes to
|
||||
// stderr (it propagates a typed error or stays silent), so every test asserts
|
||||
// this buffer stays empty as an invariant.
|
||||
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: rt}, nil
|
||||
}
|
||||
return f, errBuf
|
||||
}
|
||||
|
||||
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
|
||||
// is the same typed error every other token-resolving command returns for the
|
||||
// same bad credentials, and nothing is written to stderr (the root dispatcher
|
||||
// renders the envelope). The numeric code is not asserted: the unified v3 Token
|
||||
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected *errs.ConfigError, got nil")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
if cfgErr.Category != errs.CategoryConfig {
|
||||
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
|
||||
// written to stderr. Used for every ambiguous (non-credential) outcome.
|
||||
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Errorf("expected nil (silent), got error: %v", err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("expected no stderr output, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
|
||||
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
|
||||
// propagated. The probe endpoint must not be called when TAT fails.
|
||||
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
}
|
||||
assertConfigRejection(t, err, errBuf)
|
||||
}
|
||||
|
||||
// unauthorized_client is treated as the same credential rejection, propagated.
|
||||
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
|
||||
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
|
||||
// it rather than swallowing — but is not a credential (ConfigError) rejection.
|
||||
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if err == nil || !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
|
||||
// rejection) → silent, exit 0.
|
||||
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500} {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(code, `nope`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("network down")
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
probeHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(500, `server error`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.probeCalls != 1 {
|
||||
t.Errorf("probe should be called once, got %d", rt.probeCalls)
|
||||
}
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.tatCalls != 1 || rt.probeCalls != 1 {
|
||||
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
|
||||
}
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_ProbeRequestShape(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if rt.probeReq == nil {
|
||||
t.Fatal("probe request not captured")
|
||||
}
|
||||
if rt.probeReq.Method != http.MethodPost {
|
||||
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
|
||||
}
|
||||
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
|
||||
t.Errorf("probe URL = %s", got)
|
||||
}
|
||||
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
|
||||
t.Errorf("Authorization = %q, want Bearer t-ok", got)
|
||||
}
|
||||
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
|
||||
t.Errorf("probe body missing from field: %s", rt.probeBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rt.probeReq == nil {
|
||||
t.Fatal("probe request not captured")
|
||||
}
|
||||
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
|
||||
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return nil, errors.New("client init failed")
|
||||
}
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
<-req.Context().Done()
|
||||
return nil, req.Context().Err()
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
start := time.Now()
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > 4*time.Second {
|
||||
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
|
||||
}
|
||||
// A timeout is an ambiguous failure (context deadline → untyped), so it
|
||||
// must stay silent and not block.
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
|
||||
// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2:
|
||||
// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials,
|
||||
// not for missing user input.
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_NilConfig_EmitsValidationError(t *testing.T) {
|
||||
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", core.BrandFeishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_UnknownProfile_EmitsValidationError(t *testing.T) {
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", core.BrandFeishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_NoCurrentApp_EmitsValidationError(t *testing.T) {
|
||||
existing := &core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", core.BrandFeishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t *testing.T) {
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", core.BrandFeishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
// wrapUpdateExistingProfileErr is the caller-side classifier for the error
|
||||
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error
|
||||
// exit semantics: a typed ValidationError must keep ExitValidation rather than
|
||||
// being downgraded to InternalError.
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
|
||||
if got := wrapUpdateExistingProfileErr(nil); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T) {
|
||||
in := errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile").
|
||||
WithParam("--app-secret")
|
||||
got := wrapUpdateExistingProfileErr(in)
|
||||
assertValidationParam(t, got, "--app-secret")
|
||||
// Exit code must remain ExitValidation (2), not ExitInternal (5).
|
||||
if code := output.ExitCodeOf(got); code != output.ExitValidation {
|
||||
t.Errorf("ExitCodeOf = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// Must NOT be wrapped as *InternalError.
|
||||
var intErr *errs.InternalError
|
||||
if errors.As(got, &intErr) {
|
||||
t.Errorf("typed ValidationError was downgraded to *InternalError: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
|
||||
in := fmt.Errorf("disk full")
|
||||
got := wrapUpdateExistingProfileErr(in)
|
||||
var intErr *errs.InternalError
|
||||
if !errors.As(got, &intErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", got, got)
|
||||
}
|
||||
if intErr.Subtype != errs.SubtypeSDKError {
|
||||
t.Errorf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError)
|
||||
}
|
||||
}
|
||||
|
||||
// assertValidationParam asserts err is *ValidationError with the given Param.
|
||||
func assertValidationParam(t *testing.T, err error, wantParam string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if valErr.Param != wantParam {
|
||||
t.Errorf("Param = %q, want %q", valErr.Param, wantParam)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCmdConfigKeychainDowngrade creates the macOS-only subcommand that pins
|
||||
// the master key to the local file fallback (master.key.file) so subsequent
|
||||
// operations bypass the OS Keychain. Useful inside sandboxes like Codex
|
||||
// where the system Keychain is unreachable.
|
||||
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "keychain-downgrade",
|
||||
Short: "Downgrade keychain storage to a local file (macOS only)",
|
||||
Long: `Materialize the master key from the macOS system Keychain into a local file
|
||||
under ~/Library/Application Support/lark-cli/master.key.file, then pin all
|
||||
subsequent reads to that file.
|
||||
|
||||
Intended workflow: run this once from an interactive Terminal session on
|
||||
macOS (where the system Keychain is reachable). After it finishes,
|
||||
sandboxed / automation / CI runs of lark-cli on the same machine will read
|
||||
the master key from the local file and no longer need the OS Keychain.
|
||||
|
||||
This is the supported fix for environments like the Codex sandbox where the
|
||||
system Keychain is blocked. Running keychain-downgrade from inside such a
|
||||
sandbox will itself fail with "keychain access blocked" — that is expected;
|
||||
run it from an interactive macOS session instead.
|
||||
|
||||
The OS Keychain entry is preserved as a cold backup; nothing is deleted there.
|
||||
The command is idempotent: re-running it on an already-downgraded install
|
||||
reports "already downgraded" and exits 0.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configKeychainDowngradeRun(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func configKeychainDowngradeRun(f *cmdutil.Factory) error {
|
||||
service := keychain.LarkCliService
|
||||
keyPath := keychain.MasterKeyFilePath(service)
|
||||
|
||||
result, err := keychain.DowngradeMasterKeyToFile(service)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError,
|
||||
"keychain downgrade failed: %v", err).
|
||||
WithHint("This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
switch result {
|
||||
case keychain.DowngradeAlreadyDone:
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("keychain already downgraded; subsequent operations read from %s", keyPath))
|
||||
case keychain.DowngradeUsedKeychainKey:
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("downgraded: copied master key from system Keychain to %s. Subsequent operations will read from file, bypassing the OS Keychain (useful inside sandboxes like Codex).", keyPath))
|
||||
case keychain.DowngradeCreatedNewKey:
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("system Keychain was empty; generated a new master key and wrote it to %s. The OS Keychain was not modified.", keyPath))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !darwin
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCmdConfigKeychainDowngrade is registered on all platforms so that
|
||||
// `lark-cli config --help` reads the same everywhere. On non-macOS it
|
||||
// refuses with a clear message.
|
||||
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
|
||||
_ = f
|
||||
cmd := &cobra.Command{
|
||||
Use: "keychain-downgrade",
|
||||
Short: "Downgrade keychain storage to a local file (macOS only)",
|
||||
Long: `Downgrade keychain storage to a local file. This subcommand is only supported on macOS; on this platform the keychain layer already uses local files.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "keychain-downgrade is only supported on macOS")
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// NewCmdConfigPlugins exposes the plugin inventory diagnostic command.
|
||||
//
|
||||
// `config policy show` is intentionally focused on the user-layer Rule
|
||||
// (Restrict). Plugins also contribute hooks (Observe / Wrap / Lifecycle)
|
||||
// that are not policy gates but still mutate the CLI's runtime behaviour.
|
||||
// This command surfaces both halves so an operator can answer "what is
|
||||
// this binary doing differently from stock lark-cli?" in one place.
|
||||
//
|
||||
// Like config policy show, the dispatch path is exempt from policy
|
||||
// enforcement (see internal/cmdpolicy/diagnostic.go) so it remains
|
||||
// usable under any Rule.
|
||||
func NewCmdConfigPlugins(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "plugins",
|
||||
Hidden: true, // diagnostic-only; kept callable, omitted from --help so it stays out of AI-agent context
|
||||
Short: "Inspect installed plugins and their hook contributions",
|
||||
// Same leaf-level no-op as config policy: the parent `config`
|
||||
// group's PersistentPreRunE requires builtin credential, but
|
||||
// this is a read-only diagnostic that must work everywhere.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newCmdConfigPluginsShow(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCmdConfigPluginsShow(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "List successfully installed plugins, their rules, and registered hooks",
|
||||
Long: `Print every plugin that committed during bootstrap, including:
|
||||
|
||||
- name / version / capabilities (FailurePolicy, Restricts, RequiredCLIVersion)
|
||||
- rule (when the plugin called r.Restrict)
|
||||
- hooks: observers (Before / After), wrappers, lifecycle handlers
|
||||
|
||||
Hooks are attributed by their namespaced name -- the framework prepends
|
||||
the plugin name as the prefix at registration time, so an entry
|
||||
"secaudit.audit-pre" belongs to plugin "secaudit".`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConfigPluginsShow(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfigPluginsShow(f *cmdutil.Factory) error {
|
||||
inv := internalplatform.GetActiveInventory()
|
||||
if inv == nil {
|
||||
// Always emit the same field set as the populated branch so
|
||||
// AI agents and CI scripts don't have to branch on whether
|
||||
// `total` is present. `note` makes the unusual state explicit
|
||||
// for human readers.
|
||||
output.PrintJson(f.IOStreams.Out, map[string]any{
|
||||
"plugins": []any{},
|
||||
"total": 0,
|
||||
"note": "no inventory recorded; bootstrap did not finish",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
plugins := make([]map[string]any, 0, len(inv.Plugins))
|
||||
for _, p := range inv.Plugins {
|
||||
entry := map[string]any{
|
||||
"name": p.Name,
|
||||
"version": p.Version,
|
||||
"capabilities": p.Capabilities,
|
||||
}
|
||||
if len(p.Rules) > 0 {
|
||||
entry["rules"] = p.Rules
|
||||
}
|
||||
entry["hooks"] = map[string]any{
|
||||
"observers": p.Observers,
|
||||
"wrappers": p.Wrappers,
|
||||
"lifecycle": p.Lifecycles,
|
||||
"count": len(p.Observers) + len(p.Wrappers) + len(p.Lifecycles),
|
||||
}
|
||||
plugins = append(plugins, entry)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]any{
|
||||
"plugins": plugins,
|
||||
"total": len(plugins),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "policy",
|
||||
Hidden: true,
|
||||
Short: "Inspect the user-layer command policy",
|
||||
// Override parent's RequireBuiltinCredentialProvider check; this
|
||||
// group is read-only diagnostic and must work under any provider.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newCmdConfigPolicyShow(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCmdConfigPolicyShow(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Hidden: true,
|
||||
Short: "Show the active user-layer policy (plugin / yaml / none)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConfigPolicyShow(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfigPolicyShow(f *cmdutil.Factory) error {
|
||||
active := cmdpolicy.GetActive()
|
||||
if active == nil {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]any{
|
||||
"source": string(cmdpolicy.SourceNone),
|
||||
"note": "no policy recorded; bootstrap did not run pruning",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
sourceName := ""
|
||||
if active.Source.Kind == cmdpolicy.SourcePlugin {
|
||||
sourceName = active.Source.Name
|
||||
}
|
||||
out := map[string]any{
|
||||
"source": string(active.Source.Kind),
|
||||
"source_name": sourceName,
|
||||
"denied_paths": active.DeniedPaths,
|
||||
}
|
||||
if len(active.Rules) > 0 {
|
||||
rules := make([]map[string]any, 0, len(active.Rules))
|
||||
for _, r := range active.Rules {
|
||||
rules = append(rules, map[string]any{
|
||||
"name": r.Name,
|
||||
"description": r.Description,
|
||||
"allow": r.Allow,
|
||||
"deny": r.Deny,
|
||||
"max_risk": r.MaxRisk,
|
||||
"identities": r.Identities,
|
||||
"allow_unannotated": r.AllowUnannotated,
|
||||
})
|
||||
}
|
||||
out["rules"] = rules
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, out)
|
||||
return nil
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func newPolicyTestFactory() (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: cmdutil.NewIOStreams(nil, out, errOut),
|
||||
}
|
||||
return f, out, errOut
|
||||
}
|
||||
|
||||
// `config policy show` reads the active policy recorded by bootstrap.
|
||||
// When nothing is recorded the command must still produce a JSON
|
||||
// envelope with source=none and a note explaining the missing context.
|
||||
func TestConfigPolicyShow_NoActivePolicy(t *testing.T) {
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
if err := runConfigPolicyShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["source"] != "none" {
|
||||
t.Errorf("source = %v, want none", got["source"])
|
||||
}
|
||||
if got["note"] == "" || got["note"] == nil {
|
||||
t.Errorf("expected explanatory note when no policy recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// When bootstrap recorded an active plugin Rule, `show` emits the rule
|
||||
// plus its source.
|
||||
func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
rule := &platform.Rule{
|
||||
Name: "secaudit",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: "read",
|
||||
}
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rules: []*platform.Rule{rule},
|
||||
Source: cmdpolicy.ResolveSource{
|
||||
Kind: cmdpolicy.SourcePlugin,
|
||||
Name: "secaudit",
|
||||
},
|
||||
DeniedPaths: 42,
|
||||
})
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
if err := runConfigPolicyShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["source"] != "plugin" {
|
||||
t.Errorf("source = %v, want plugin", got["source"])
|
||||
}
|
||||
if got["source_name"] != "secaudit" {
|
||||
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"])
|
||||
}
|
||||
rulesAny, ok := got["rules"].([]any)
|
||||
if !ok || len(rulesAny) != 1 {
|
||||
t.Fatalf("rules field missing or wrong shape: %v", got["rules"])
|
||||
}
|
||||
ruleMap, ok := rulesAny[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("rules[0] wrong type")
|
||||
}
|
||||
if ruleMap["name"] != "secaudit" {
|
||||
t.Errorf("rules[0].name = %v", ruleMap["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// `source_name` must be empty when source=yaml. The yaml path is
|
||||
// deliberately not surfaced (matches engine envelope convention,
|
||||
// avoids leaking the user's home dir to AI agents / CI logs). The
|
||||
// rule's "name:" field is the disambiguator users should rely on.
|
||||
func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rules: []*platform.Rule{{Name: "my-yaml-rule"}},
|
||||
Source: cmdpolicy.ResolveSource{
|
||||
Kind: cmdpolicy.SourceYAML,
|
||||
Name: "/Users/alice/.lark-cli/policy.yml",
|
||||
},
|
||||
})
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
if err := runConfigPolicyShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["source"] != "yaml" {
|
||||
t.Errorf("source = %v, want yaml", got["source"])
|
||||
}
|
||||
if got["source_name"] != "" {
|
||||
t.Errorf("source_name = %q, want empty (yaml path must not leak)", got["source_name"])
|
||||
}
|
||||
// The path must not appear anywhere in the envelope.
|
||||
if bytes.Contains(out.Bytes(), []byte("/Users/alice")) {
|
||||
t.Errorf("envelope leaked yaml path: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the parent `config` command declares a PersistentPreRunE
|
||||
// that calls RequireBuiltinCredentialProvider; env credentials cause
|
||||
// it to return external_provider. `config policy` is a diagnostic
|
||||
// group that must not be blocked by that check. The group declares
|
||||
// its own no-op PersistentPreRunE so cobra's "first walking up from
|
||||
// leaf" picks ours over the config parent's.
|
||||
func TestConfigPolicy_BypassesConfigParentPersistentPreRunE(t *testing.T) {
|
||||
f, _, _ := newPolicyTestFactory()
|
||||
group := NewCmdConfigPolicy(f)
|
||||
if group.PersistentPreRunE == nil {
|
||||
t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent")
|
||||
}
|
||||
if err := group.PersistentPreRunE(group, nil); err != nil {
|
||||
t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -33,7 +32,6 @@ func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) erro
|
||||
return configRemoveRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -43,14 +41,14 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
|
||||
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil || config == nil || len(config.Apps) == 0 {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet")
|
||||
return output.ErrValidation("not configured yet")
|
||||
}
|
||||
|
||||
// Save empty config first. If this fails, keep secrets and tokens intact so the
|
||||
// existing config can still be retried instead of ending up half-removed.
|
||||
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
||||
if err := core.SaveMultiAppConfig(empty); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
// Clean up keychain entries for all apps after config is cleared.
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -35,7 +34,6 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
|
||||
return configShowRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -48,14 +46,14 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli profile list")
|
||||
}
|
||||
users := "(no logged-in users)"
|
||||
if len(app.Users) > 0 {
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -66,21 +66,20 @@ explicit user confirmation — never run on your own initiative.`,
|
||||
|
||||
cmd.Flags().BoolVar(&global, "global", false, "set at global level (applies to all profiles)")
|
||||
cmd.Flags().BoolVar(&reset, "reset", false, "reset profile setting to inherit global")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
||||
if global {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset")
|
||||
return output.ErrValidation("--reset cannot be used with --global")
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset")
|
||||
return output.ErrValidation("--reset cannot be used with a value argument")
|
||||
}
|
||||
app.StrictMode = nil
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
|
||||
return nil
|
||||
@@ -104,7 +103,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
switch mode {
|
||||
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value)
|
||||
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
|
||||
}
|
||||
|
||||
// Capture the old mode at the SAME scope being changed, so we can warn
|
||||
@@ -144,7 +143,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
||||
|
||||
@@ -97,7 +97,7 @@ func diagBuild(domains []string) diagOutput {
|
||||
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
|
||||
continue
|
||||
}
|
||||
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
|
||||
for _, scope := range sc.ScopesForIdentity(identity) {
|
||||
k := methodKey{domain, "shortcut", sc.Command, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
@@ -169,25 +169,6 @@ func appendUniq(ss []string, s string) []string {
|
||||
return append(ss, s)
|
||||
}
|
||||
|
||||
func TestDiagBuild_ShortcutIncludesConditionalScopes(t *testing.T) {
|
||||
out := diagBuild([]string{"drive"})
|
||||
var sawMetadata, sawDownload bool
|
||||
for _, method := range out.Methods {
|
||||
if method.Domain != "drive" || method.Type != "shortcut" || method.Method != "+status" {
|
||||
continue
|
||||
}
|
||||
if method.Scope == "drive:drive.metadata:readonly" {
|
||||
sawMetadata = true
|
||||
}
|
||||
if method.Scope == "drive:file:download" {
|
||||
sawDownload = true
|
||||
}
|
||||
}
|
||||
if !sawMetadata || !sawDownload {
|
||||
t.Fatalf("drive +status should advertise both metadata and conditional download scopes, saw metadata=%v download=%v", sawMetadata, sawDownload)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snapshot generation ───────────────────────────────────────────────
|
||||
//
|
||||
// Generates a JSON snapshot of all API methods and shortcuts with their
|
||||
|
||||
@@ -14,13 +14,11 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
@@ -45,7 +43,6 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmd.Flags().BoolVar(&opts.Offline, "offline", false, "skip network checks (only verify local state)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -53,7 +50,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
// checkResult represents one diagnostic check.
|
||||
type checkResult struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // "pass", "warn", "fail", "skip"
|
||||
Status string `json:"status"` // "pass", "fail", "skip"
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
@@ -95,7 +92,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
// underlying problem is still visible.
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
@@ -109,7 +106,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
hint := ""
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
hint = cfgErr.Hint
|
||||
}
|
||||
@@ -120,31 +117,59 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
|
||||
ep := core.ResolveEndpoints(cfg.Brand)
|
||||
|
||||
// ── 3. Identity readiness ──
|
||||
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
|
||||
checks = append(checks,
|
||||
identityCheck("bot_identity", diagnostics.Bot),
|
||||
identityCheck("user_identity", diagnostics.User),
|
||||
)
|
||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
// ── 3. Token exists ──
|
||||
if cfg.UserOpenId == "" {
|
||||
checks = append(checks, fail("token_exists", "no user logged in", "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
if stored == nil {
|
||||
checks = append(checks, fail("token_exists", "no token in keychain for "+cfg.UserOpenId, "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
checks = append(checks, pass("token_exists", fmt.Sprintf("token found for %s (%s)", cfg.UserName, cfg.UserOpenId)))
|
||||
|
||||
// ── 4. Token local validity ──
|
||||
status := larkauth.TokenStatus(stored)
|
||||
switch status {
|
||||
case "valid":
|
||||
checks = append(checks, pass("token_local", "token valid, expires "+time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)))
|
||||
case "needs_refresh":
|
||||
checks = append(checks, pass("token_local", "token needs refresh (will auto-refresh on next call)"))
|
||||
default: // expired
|
||||
checks = append(checks, fail("token_local", "token expired", "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
// ── 5. Token server verification ──
|
||||
if opts.Offline {
|
||||
checks = append(checks, skip("token_verified", "skipped (--offline)"))
|
||||
} else {
|
||||
httpClient := mustHTTPClient(f)
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
checks = append(checks, fail("token_verified", "cannot obtain valid token: "+err.Error(), "run: lark-cli auth login --help"))
|
||||
} else {
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
checks = append(checks, fail("token_verified", "SDK init failed: "+err.Error(), ""))
|
||||
} else if err := larkauth.VerifyUserToken(opts.Ctx, sdk, token); err != nil {
|
||||
checks = append(checks, fail("token_verified", "server rejected token: "+err.Error(), "run: lark-cli auth login --help"))
|
||||
} else {
|
||||
checks = append(checks, pass("token_verified", "server confirmed token is valid"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6 & 7. Endpoint reachability ──
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
func identityCheck(name string, id identitydiag.Identity) checkResult {
|
||||
if id.Available {
|
||||
return pass(name, id.Message)
|
||||
}
|
||||
return warn(name, id.Message, id.Hint)
|
||||
}
|
||||
|
||||
// networkChecks probes Open API and MCP endpoints concurrently.
|
||||
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
|
||||
if opts.Offline {
|
||||
@@ -154,9 +179,7 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
|
||||
// the real egress path (and are blocked when proxy plugin fails closed).
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
httpClient := &http.Client{}
|
||||
mcpURL := ep.MCP + "/mcp"
|
||||
|
||||
type probeResult struct {
|
||||
@@ -208,6 +231,15 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mustHTTPClient returns f.HttpClient() or a default client.
|
||||
func mustHTTPClient(f *cmdutil.Factory) *http.Client {
|
||||
c, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// checkCLIUpdate actively queries the npm registry for the latest version.
|
||||
// Unlike the root-level async check, this does a synchronous fetch with timeout
|
||||
// and works regardless of build version (dev builds included).
|
||||
@@ -220,7 +252,7 @@ func checkCLIUpdate() []checkResult {
|
||||
if update.IsNewer(latest, current) {
|
||||
return []checkResult{warn("cli_update",
|
||||
fmt.Sprintf("%s → %s available", current, latest),
|
||||
"run: lark-cli update")}
|
||||
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
|
||||
}
|
||||
return []checkResult{pass("cli_update", latest+" (up to date)")}
|
||||
}
|
||||
|
||||
@@ -95,59 +95,3 @@ func TestNetworkChecks_Offline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
err := doctorRun(&DoctorOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Offline: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("doctorRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if !got.OK {
|
||||
t.Fatalf("ok = false, want true; checks = %#v", got.Checks)
|
||||
}
|
||||
assertCheck(t, got.Checks, "bot_identity", "pass")
|
||||
assertCheck(t, got.Checks, "user_identity", "warn")
|
||||
assertCheck(t, got.Checks, "identity_ready", "pass")
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.Name == name {
|
||||
if check.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %#v", name, checks)
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// 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
|
||||
// locally (via shortcut registration or service-method metadata). Existing
|
||||
// Hint text is preserved; scopes are appended on a new line.
|
||||
func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
||||
if err == nil || f == nil {
|
||||
return
|
||||
}
|
||||
if !internalauth.IsNeedUserAuthorizationError(err) {
|
||||
return
|
||||
}
|
||||
var authErr *errs.AuthenticationError
|
||||
if !errors.As(err, &authErr) {
|
||||
return
|
||||
}
|
||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||
if len(scopes) == 0 {
|
||||
return
|
||||
}
|
||||
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
|
||||
if authErr.Hint == "" {
|
||||
authErr.Hint = scopeHint
|
||||
return
|
||||
}
|
||||
authErr.Hint += "\n" + scopeHint
|
||||
}
|
||||
|
||||
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
|
||||
// current command for the resolved identity, checking shortcuts first and then
|
||||
// service methods from local registry metadata.
|
||||
func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string {
|
||||
if f == nil || f.CurrentCommand == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
identity := string(f.ResolvedIdentity)
|
||||
if identity == "" {
|
||||
identity = string(core.AsUser)
|
||||
}
|
||||
if identity != string(core.AsUser) && identity != string(core.AsBot) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if scopes := resolveDeclaredShortcutScopes(f.CurrentCommand, identity); len(scopes) > 0 {
|
||||
return scopes
|
||||
}
|
||||
return resolveDeclaredServiceMethodScopes(f.CurrentCommand, identity)
|
||||
}
|
||||
|
||||
// resolveDeclaredShortcutScopes returns the scopes declared by a mounted
|
||||
// shortcut command for the given identity.
|
||||
func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string {
|
||||
if cmd == nil || cmd.Parent() == nil || !strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
|
||||
service := cmd.Parent().Name()
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
scopes := sc.DeclaredScopesForIdentity(identity)
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), scopes...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
|
||||
// service/resource/method command. It reconstructs the catalog path from the
|
||||
// command ancestry and resolves it through the same navigation Module the
|
||||
// command tree is built from (apicatalog), so it stays correct for nested
|
||||
// resources instead of hard-coding a root->service->resource->method depth.
|
||||
// Non-method commands (services, resources, shortcuts) resolve to a non-method
|
||||
// target and yield no scopes.
|
||||
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
|
||||
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
path := commandCatalogPath(cmd)
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
target, err := registry.RuntimeCatalog().Resolve(path)
|
||||
if err != nil || target.Kind != apicatalog.TargetMethod {
|
||||
return nil
|
||||
}
|
||||
return registry.DeclaredScopesForMethod(target.Method.Method, identity)
|
||||
}
|
||||
|
||||
// commandCatalogPath reconstructs the catalog path [service, resource..., method]
|
||||
// from a command's ancestry, excluding the root command. It is the inverse of
|
||||
// the service command tree's construction, so any depth (flat or nested)
|
||||
// round-trips through apicatalog.Resolve.
|
||||
func commandCatalogPath(cmd *cobra.Command) []string {
|
||||
var path []string
|
||||
for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() {
|
||||
path = append([]string{c.Name()}, path...)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||
// identity, applying the default user-only behavior when AuthTypes is empty.
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{string(core.AsUser)}
|
||||
}
|
||||
for _, authType := range authTypes {
|
||||
if authType == identity {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
|
||||
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
|
||||
|
||||
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
@@ -39,8 +38,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"set up bus logger: %s", err).WithCause(err)
|
||||
return err
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
@@ -60,20 +58,12 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := b.Run(ctx); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus daemon exited: %s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
return b.Run(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&domain, "domain", "", "API domain")
|
||||
_ = cmd.Flags().MarkHidden("domain")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// The hidden `event _bus` daemon command must exit with a typed file_io error
|
||||
// when its log directory cannot be created (the error is only visible in the
|
||||
// forked process's captured stderr / bus.log).
|
||||
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Block the events/ root with a regular file so MkdirAll fails.
|
||||
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdBus(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected logger setup error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryInternal, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
@@ -4,117 +4,21 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Landing-page contract for the scan-to-enable deep link, verified against the
|
||||
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
|
||||
// Note the param is camelCase "clientID" (not snake_case), and the value is the
|
||||
// consuming app's own ID. Centralized so it can be corrected in one place.
|
||||
const (
|
||||
addonsLandingPath = "/page/launcher"
|
||||
addonsClientIDParam = "clientID"
|
||||
)
|
||||
|
||||
// ManifestAddons mirrors the 5 public manifest sections the launcher page accepts.
|
||||
// Encoded form: JSON -> gzip -> base64url(no padding).
|
||||
type ManifestAddons struct {
|
||||
Scopes *AddonsScopes `json:"scopes,omitempty"`
|
||||
Events *AddonsEvents `json:"events,omitempty"`
|
||||
Callbacks *AddonsCallbacks `json:"callbacks,omitempty"`
|
||||
}
|
||||
|
||||
type AddonsScopes struct {
|
||||
Tenant []string `json:"tenant"`
|
||||
User []string `json:"user"`
|
||||
}
|
||||
|
||||
type AddonsEvents struct {
|
||||
Items AddonsEventItems `json:"items"`
|
||||
}
|
||||
|
||||
type AddonsEventItems struct {
|
||||
Tenant []string `json:"tenant"`
|
||||
User []string `json:"user"`
|
||||
}
|
||||
|
||||
type AddonsCallbacks struct {
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
|
||||
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
|
||||
func encodeAddons(a ManifestAddons) (string, error) {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
if _, err := gw.Write(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
|
||||
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
|
||||
encoded, err := encodeAddons(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
|
||||
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
|
||||
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
|
||||
host, appID, strings.Join(scopes, ","))
|
||||
}
|
||||
|
||||
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
|
||||
func consoleLandingURL(brand core.LarkBrand, appID string) string {
|
||||
// consoleEventSubscriptionURL points at the app's event subscription console page.
|
||||
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
|
||||
}
|
||||
|
||||
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
|
||||
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
|
||||
url, err := consoleAddonsURL(brand, appID, a)
|
||||
if err != nil {
|
||||
return consoleLandingURL(brand, appID)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// missingScopeAddons routes missing scopes into the identity-appropriate section.
|
||||
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
|
||||
// the addons spec treats a missing tenant/user as an empty array.
|
||||
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
|
||||
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
|
||||
if identity.IsBot() {
|
||||
s.Tenant = missing
|
||||
} else {
|
||||
s.User = missing
|
||||
}
|
||||
return ManifestAddons{Scopes: s}
|
||||
}
|
||||
|
||||
// missingSubscriptionAddons routes missing events/callbacks into the right section.
|
||||
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
|
||||
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
|
||||
if subType == eventlib.SubTypeCallback {
|
||||
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
|
||||
}
|
||||
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
|
||||
if identity.IsBot() {
|
||||
ev.Items.Tenant = missing
|
||||
} else {
|
||||
ev.Items.User = missing
|
||||
}
|
||||
return ManifestAddons{Events: ev}
|
||||
return fmt.Sprintf("%s/app/%s/event", host, appID)
|
||||
}
|
||||
|
||||
@@ -4,109 +4,33 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
|
||||
t.Helper()
|
||||
gz, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("base64url decode: %v", err)
|
||||
}
|
||||
zr, err := gzip.NewReader(bytes.NewReader(gz))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip reader: %v", err)
|
||||
}
|
||||
raw, err := io.ReadAll(zr)
|
||||
if err != nil {
|
||||
t.Fatalf("gunzip: %v", err)
|
||||
}
|
||||
var a ManifestAddons
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func TestEncodeAddons_RoundTrip(t *testing.T) {
|
||||
in := ManifestAddons{Scopes: &AddonsScopes{Tenant: []string{"im:message"}}}
|
||||
encoded, err := encodeAddons(in)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
for _, r := range encoded {
|
||||
if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
|
||||
t.Fatalf("encoded contains non-base64url char %q in %q", r, encoded)
|
||||
}
|
||||
}
|
||||
out := decodeAddons(t, encoded)
|
||||
if out.Scopes == nil || len(out.Scopes.Tenant) != 1 || out.Scopes.Tenant[0] != "im:message" {
|
||||
t.Errorf("roundtrip mismatch: %+v", out)
|
||||
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
|
||||
"im:message:readonly",
|
||||
"im:message.group_at_msg",
|
||||
})
|
||||
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
|
||||
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("url: %v", err)
|
||||
}
|
||||
host := core.ResolveEndpoints(core.BrandFeishu).Open
|
||||
prefix := host + "/page/launcher?clientID=cli_x&addons="
|
||||
if !strings.HasPrefix(url, prefix) {
|
||||
t.Errorf("url = %q, want prefix %q", url, prefix)
|
||||
}
|
||||
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
|
||||
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
|
||||
t.Errorf("decoded callbacks mismatch: %+v", out)
|
||||
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
|
||||
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
|
||||
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
|
||||
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
|
||||
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
|
||||
}
|
||||
user := missingScopeAddons(core.AsUser, []string{"im:message"})
|
||||
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
|
||||
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
|
||||
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
|
||||
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
|
||||
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
|
||||
}
|
||||
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
|
||||
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
|
||||
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
|
||||
// Unused identity sides must encode as [] (not null) so the launcher page's
|
||||
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
|
||||
cases := []ManifestAddons{
|
||||
missingScopeAddons(core.AsBot, []string{"im:message"}),
|
||||
missingScopeAddons(core.AsUser, []string{"im:message"}),
|
||||
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
|
||||
}
|
||||
for i, a := range cases {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("case %d marshal: %v", i, err)
|
||||
}
|
||||
if bytes.Contains(raw, []byte("null")) {
|
||||
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
|
||||
}
|
||||
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
|
||||
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
|
||||
t.Errorf("unexpected url: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -65,13 +64,12 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
|
||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -102,10 +100,11 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--jq").
|
||||
WithCause(err).
|
||||
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
err.Error(),
|
||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,28 +145,14 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
|
||||
}
|
||||
|
||||
// Callback subscriptions live in application/get, not app_versions; fetch the
|
||||
// callback 底账 only for callback-type EventKeys. Weak dependency: on error,
|
||||
// leave subscribedCallbacks nil so the callback precheck skips.
|
||||
var subscribedCallbacks []string
|
||||
if keyDef.SubscriptionType == eventlib.SubTypeCallback {
|
||||
cbs, cbErr := appmeta.FetchSubscribedCallbacks(cmd.Context(), botRuntime, cfg.AppID)
|
||||
if cbErr != nil {
|
||||
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(cbErr))
|
||||
} else {
|
||||
subscribedCallbacks = cbs
|
||||
}
|
||||
}
|
||||
|
||||
pf := &preflightCtx{
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
subscribedCallbacks: subscribedCallbacks,
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
return err
|
||||
@@ -198,9 +183,8 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
|
||||
// Bounded runs already have --max-events/--timeout as their lifecycle control.
|
||||
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
|
||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
@@ -243,9 +227,6 @@ type preflightCtx struct {
|
||||
identity core.Identity
|
||||
keyDef *eventlib.KeyDefinition
|
||||
appVer *appmeta.AppVersion
|
||||
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
|
||||
// nil means "not fetched / unavailable" → callback precheck skips (weak dependency).
|
||||
subscribedCallbacks []string
|
||||
}
|
||||
|
||||
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
|
||||
@@ -278,87 +259,63 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return 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))
|
||||
return output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
||||
)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
|
||||
if identity.IsBot() {
|
||||
return fmt.Sprintf("grant these scopes by scanning: %s",
|
||||
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
|
||||
return fmt.Sprintf(
|
||||
"grant these scopes and publish a new app version at: %s",
|
||||
consoleScopeGrantURL(brand, appID, 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, " "))
|
||||
strings.Join(missing, " "),
|
||||
)
|
||||
}
|
||||
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
|
||||
// in the app's console 底账 — published app_versions for event subscriptions,
|
||||
// application/get subscribed_callbacks for callback subscriptions.
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
|
||||
func preflightEventTypes(pf *preflightCtx) error {
|
||||
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var subscribed []string
|
||||
noun := "event types"
|
||||
if pf.keyDef.SubscriptionType == eventlib.SubTypeCallback {
|
||||
if pf.subscribedCallbacks == nil {
|
||||
return nil
|
||||
}
|
||||
subscribed = pf.subscribedCallbacks
|
||||
noun = "callbacks"
|
||||
} else {
|
||||
if pf.appVer == nil {
|
||||
return nil
|
||||
}
|
||||
subscribed = pf.appVer.EventTypes
|
||||
}
|
||||
|
||||
have := make(map[string]bool, len(subscribed))
|
||||
for _, t := range subscribed {
|
||||
have[t] = true
|
||||
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
|
||||
for _, t := range pf.appVer.EventTypes {
|
||||
subscribed[t] = true
|
||||
}
|
||||
var missing []string
|
||||
for _, t := range pf.keyDef.RequiredConsoleEvents {
|
||||
if !have[t] {
|
||||
if !subscribed[t] {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"EventKey %s requires %s not subscribed in console: %s",
|
||||
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these %s by scanning: %s", noun, url)
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
||||
)
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
func sanitizeOutputDir(dir string) (string, error) {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirTilde)
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirUnsafe)
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
@@ -370,25 +327,22 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"resolve tenant access token: %s", err).WithCause(err)
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
}
|
||||
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'.")
|
||||
return "", output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
||||
)
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format") //nolint:forbidigo // sentinel, typed at call sites
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") //nolint:forbidigo // sentinel, typed at call sites
|
||||
errOutputDirUnsafe = errors.New("unsafe --output-dir") //nolint:forbidigo // sentinel, typed at call sites
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
errOutputDirUnsafe = errors.New("unsafe --output-dir")
|
||||
)
|
||||
|
||||
func parseParams(raw []string) (map[string]string, error) {
|
||||
@@ -396,10 +350,7 @@ func parseParams(raw []string) (map[string]string, error) {
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||
WithParam("--param").
|
||||
WithCause(errInvalidParamFormat)
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
@@ -418,8 +369,3 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
|
||||
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
|
||||
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
|
||||
return !isTerminal && maxEvents <= 0 && timeout <= 0
|
||||
}
|
||||
|
||||
@@ -61,70 +61,3 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldWatchStdinEOF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isTerminal bool
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "terminal",
|
||||
isTerminal: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal unbounded",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative max events is unbounded",
|
||||
maxEvents: -1,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative timeout is unbounded",
|
||||
timeout: -1 * time.Second,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal max events bounded",
|
||||
maxEvents: 1,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal timeout bounded",
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal both bounds positive",
|
||||
maxEvents: 1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded max events with negative timeout",
|
||||
maxEvents: 1,
|
||||
timeout: -1 * time.Second,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded timeout with negative max events",
|
||||
maxEvents: -1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
|
||||
if got != tt.want {
|
||||
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,9 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
@@ -78,7 +73,6 @@ func TestParseParams(t *testing.T) {
|
||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
||||
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--param")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -96,77 +90,6 @@ func TestParseParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// emptyTokenResolver resolves to a result that carries no token.
|
||||
type emptyTokenResolver struct{}
|
||||
|
||||
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{}, nil
|
||||
}
|
||||
|
||||
// failingTokenResolver fails outright with an untyped error.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, errors.New("backend unavailable")
|
||||
}
|
||||
|
||||
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
var malformed *credential.MalformedTokenResultError
|
||||
if !errors.As(err, &malformed) {
|
||||
t.Error("empty-token failure should preserve the credential-layer cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("resolver failure should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
// assertInvalidArgumentParam verifies err is a typed validation error with
|
||||
// subtype invalid_argument naming the given flag in its param field.
|
||||
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != param {
|
||||
t.Errorf("param = %q, want %q", ve.Param, param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutputDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -207,7 +130,6 @@ func TestSanitizeOutputDir(t *testing.T) {
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--output-dir")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -143,79 +143,6 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 2,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
|
||||
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB column header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "alice") {
|
||||
t.Errorf("missing alice suffix in SUB column: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "bob") {
|
||||
t.Errorf("missing bob suffix in SUB column: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStatusJSON(&buf, []appStatus{
|
||||
@@ -270,15 +197,15 @@ func TestExitForOrphan(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("flag on + orphan → expected error, got nil")
|
||||
}
|
||||
var exit *output.BareError
|
||||
var exit *output.ExitError
|
||||
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %v, want ExitValidation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func errorAs(err error, target interface{}) bool {
|
||||
if e, ok := err.(*output.BareError); ok {
|
||||
if t, ok := target.(**output.BareError); ok {
|
||||
if e, ok := err.(*output.ExitError); ok {
|
||||
if t, ok := target.(**output.ExitError); ok {
|
||||
*t = e
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
@@ -56,17 +55,4 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var foundTask bool
|
||||
for _, row := range rows {
|
||||
if row["key"] == "task.task.update_user_access_v2" {
|
||||
foundTask = true
|
||||
if row["single_consumer"] != true {
|
||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundTask {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
@@ -89,17 +89,19 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||
t.Errorf("error should name the missing event type, got: %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint")
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,22 +145,21 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||
t.Errorf("error should name missing scope, got: %v", err)
|
||||
}
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
|
||||
errs.CategoryAuthorization, errs.SubtypeMissingScope)
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
}
|
||||
wantMissing := []string{"im:message.group_at_msg"}
|
||||
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
|
||||
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
}
|
||||
hint := permErr.Hint
|
||||
hint := exit.Detail.Hint
|
||||
wantSubstrings := []string{
|
||||
"grant these scopes by scanning: ",
|
||||
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
"token_type=tenant",
|
||||
}
|
||||
for _, want := range wantSubstrings {
|
||||
if !strings.Contains(hint, want) {
|
||||
@@ -173,109 +174,3 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
|
||||
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{"profile.view.get"},
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
err := preflightEventTypes(pf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing callback")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "callbacks not subscribed") {
|
||||
t.Errorf("error = %q, want mention of 'callbacks not subscribed'", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "card.action.trigger") {
|
||||
t.Errorf("error should name the missing callback, got: %q", err.Error())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %v, want validation/failed_precondition", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
t.Errorf("expected skip (nil), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
|
||||
// fetched but zero callbacks subscribed (non-nil empty) is a definitive
|
||||
// console state: a required callback IS missing and must be reported,
|
||||
// not skipped as a weak dependency.
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{}, // fetched, none subscribed
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
err := preflightEventTypes(pf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing callback when none are subscribed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "card.action.trigger") {
|
||||
t.Errorf("error should name the missing callback, got: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
t.Errorf("all callbacks subscribed, unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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"})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ package event
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -26,11 +26,7 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
return nil, err
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
@@ -40,22 +36,13 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
return nil, err
|
||||
}
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
}
|
||||
return json.RawMessage(resp.RawBody), nil
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
type staticTokenResolver struct{}
|
||||
|
||||
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
// stubRoundTripper intercepts every outgoing request with a canned response.
|
||||
type stubRoundTripper struct {
|
||||
respond func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
|
||||
|
||||
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
return &consumeRuntime{
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
|
||||
return func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{contentType}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
if !strings.Contains(err.Error(), "returned 404") {
|
||||
t.Errorf("error should echo the HTTP status, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
|
||||
long := strings.Repeat("x", 300)
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if !p.Retryable {
|
||||
t.Fatal("5xx non-JSON response should be marked retryable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "…(truncated)") {
|
||||
t.Errorf("long body should be truncated in the message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":99991663,"msg":"app not found"}`)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":0,"data":{"ok":true}}`)})
|
||||
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"code":0`) {
|
||||
t.Errorf("raw body should pass through, got: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
@@ -40,14 +39,12 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||
return nil, nil, err
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
@@ -76,7 +73,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -91,7 +88,6 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -134,16 +130,12 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if len(def.Params) > 0 {
|
||||
fmt.Fprintf(out, "\nParameters:\n")
|
||||
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n")
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
|
||||
for _, p := range def.Params {
|
||||
required := "no"
|
||||
if p.Required {
|
||||
required = "yes"
|
||||
}
|
||||
subKey := "no"
|
||||
if p.SubscriptionKey {
|
||||
subKey = "yes"
|
||||
}
|
||||
defaultVal := p.Default
|
||||
if defaultVal == "" {
|
||||
defaultVal = "-"
|
||||
@@ -152,7 +144,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc)
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
@@ -172,7 +164,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
@@ -96,107 +95,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["jq_root_path"] != ".event" {
|
||||
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
|
||||
}
|
||||
if payload["single_consumer"] != true {
|
||||
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
|
||||
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{
|
||||
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
|
||||
{Name: "folders", Description: "filter only"},
|
||||
},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "SUB-KEY") {
|
||||
t.Errorf("missing SUB-KEY column header in:\n%s", out)
|
||||
}
|
||||
|
||||
// Find the mailbox row and verify "yes" is present
|
||||
var mailboxRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
|
||||
mailboxRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(mailboxRow, "yes") {
|
||||
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
|
||||
}
|
||||
|
||||
// Find the folders row and verify "no" is present
|
||||
var foldersRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
|
||||
foldersRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(foldersRow, "no") {
|
||||
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||
const syntheticKey = "test.evt_json"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), `"subscription_key"`) {
|
||||
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `true`) {
|
||||
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
const syntheticKey = "t.custom.overlay"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
@@ -231,38 +129,3 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
t.Errorf("overlay format = %v, want open_id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
|
||||
_, err := renderSpec(&eventlib.SchemaSpec{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for spec with neither Type nor Raw")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "synthetic.invalid.base",
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
|
||||
},
|
||||
}
|
||||
_, _, err := resolveSchemaJSON(def)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unparsable base schema")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -38,7 +37,6 @@ func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit status as JSON (for AI / scripts)")
|
||||
cmd.Flags().BoolVar(¤t, "current", false, "Only show status for the current profile's app")
|
||||
cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "Exit 2 when any orphan bus is detected (default: always exit 0)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -243,17 +241,12 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
|
||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||
if len(s.Consumers) > 0 {
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"}
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
|
||||
rows := make([][]string, 0, len(s.Consumers))
|
||||
for _, c := range s.Consumers {
|
||||
subDisplay := "-"
|
||||
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
|
||||
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
fmt.Sprintf("pid=%d", c.PID),
|
||||
c.EventKey,
|
||||
subDisplay,
|
||||
fmt.Sprintf("%d", c.Received),
|
||||
fmt.Sprintf("%d", c.Dropped),
|
||||
})
|
||||
|
||||
@@ -19,12 +19,12 @@ func TestExitForOrphan_Orphan(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error when failOnOrphan=true and orphan present")
|
||||
}
|
||||
var bareErr *output.BareError
|
||||
if !errors.As(err, &bareErr) {
|
||||
t.Fatalf("expected *output.BareError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if bareErr.Code != output.ExitValidation {
|
||||
t.Errorf("Code = %d, want %d", bareErr.Code, output.ExitValidation)
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ Exit code: 2 if any target was refused or errored, 0 otherwise.
|
||||
cmd.Flags().BoolVar(&o.all, "all", false, "Stop all running bus daemons")
|
||||
cmd.Flags().BoolVar(&o.force, "force", false, "Stop even with active consumers; on shutdown-timeout also SIGKILL the bus")
|
||||
cmd.Flags().BoolVar(&o.asJSON, "json", false, "Emit results as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const maxSuggestions = 3
|
||||
@@ -29,7 +28,7 @@ func suggestEventKeys(input string) []string {
|
||||
hits = append(hits, match{def.Key, 0})
|
||||
continue
|
||||
}
|
||||
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
|
||||
if d := levenshtein(input, def.Key); d <= threshold {
|
||||
hits = append(hits, match{def.Key, d})
|
||||
}
|
||||
}
|
||||
@@ -64,6 +63,40 @@ func unknownEventKeyErr(key string) error {
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithHint("Run 'lark-cli event list' to see available keys.")
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
}
|
||||
|
||||
// levenshtein computes classic edit distance (two-row DP).
|
||||
func levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,27 @@ import (
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"a", "", 1},
|
||||
{"", "abc", 3},
|
||||
{"kitten", "kitten", 0},
|
||||
{"kitten", "sitten", 1},
|
||||
{"kitten", "sitting", 3},
|
||||
{"飞书", "飞书", 0},
|
||||
{"飞书", "飞s", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := levenshtein(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestEventKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUnknownFlagName(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"unknown flag: --query", "query", true},
|
||||
{"unknown flag: --with-styles", "with-styles", true},
|
||||
{"unknown shorthand flag: 'z' in -z", "", false},
|
||||
{"flag needs an argument: --find", "", false},
|
||||
{`invalid argument "x" for "--count"`, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, ok := unknownFlagName(errors.New(c.in))
|
||||
if name != c.name || ok != c.ok {
|
||||
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
c.Flags().String("find", "", "")
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// The offending flag is carried structurally on Params (replaces the
|
||||
// legacy detail map) and named in the message.
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--rang" {
|
||||
t.Errorf("Params = %v, want one entry named --rang", verr.Params)
|
||||
}
|
||||
if len(verr.Params) == 1 && verr.Params[0].Reason == "" {
|
||||
t.Error("Params[0].Reason must explain the rejection")
|
||||
}
|
||||
if !strings.Contains(verr.Message, "--rang") {
|
||||
t.Errorf("message should name the offending flag, got %q", verr.Message)
|
||||
}
|
||||
// The ranked candidate rides on the param as a machine-readable suggestion
|
||||
// so an agent can retry without parsing prose.
|
||||
if len(verr.Params) == 1 {
|
||||
found := false
|
||||
for _, s := range verr.Params[0].Suggestions {
|
||||
if s == "--range" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Params[0].Suggestions should include --range, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
}
|
||||
// The same candidate is also carried in the human-facing hint.
|
||||
if !strings.Contains(verr.Hint, "--range") {
|
||||
t.Errorf("hint should suggest --range, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
|
||||
// structured param, generic --help hint (no "did you mean" suggestion).
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if verr.Param != "" || len(verr.Params) != 0 {
|
||||
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func TestIsSingleAppMode_MultiApp(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildInternal_HideProfileOption(t *testing.T) {
|
||||
_, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
@@ -90,7 +90,7 @@ func TestBuildInternal_HideProfileOption(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
|
||||
_, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
)
|
||||
|
||||
// composePendingNotice must surface a deprecated-command alias under the
|
||||
// "deprecated_command" key, with the migration target and a skill-update hint,
|
||||
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
|
||||
// without ever reading --help.
|
||||
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+read",
|
||||
Replacement: "+cells-get",
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
|
||||
got := composePendingNotice()
|
||||
if got == nil {
|
||||
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
|
||||
}
|
||||
entry, ok := got["deprecated_command"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing deprecated_command key: %#v", got)
|
||||
}
|
||||
if entry["command"] != "+read" {
|
||||
t.Errorf("command = %v, want +read", entry["command"])
|
||||
}
|
||||
if entry["replacement"] != "+cells-get" {
|
||||
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
|
||||
}
|
||||
if entry["skill"] != "lark-sheets" {
|
||||
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
|
||||
}
|
||||
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
|
||||
t.Errorf("message missing skill-update hint: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing pending, the provider returns nil so no "_notice" field is
|
||||
// emitted on a clean run.
|
||||
func TestComposePendingNoticeEmpty(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
if got := composePendingNotice(); 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 {
|
||||
t.Fatalf("deprecated_command present after clear: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// userPolicyFileName is the conventional filename for the user-layer Rule.
|
||||
// Lives under ~/.lark-cli/ to match the rest of the CLI's user-state
|
||||
// directory.
|
||||
const userPolicyFileName = "policy.yml"
|
||||
|
||||
// applyUserPolicyPruning resolves the user-layer Rule from plugin
|
||||
// contributions and/or ~/.lark-cli/policy.yml and installs denyStubs
|
||||
// for commands it rejects.
|
||||
//
|
||||
// Missing yaml is not an error -- the CLI runs with no user-layer
|
||||
// restriction. A malformed Rule (bad MaxRisk enum, malformed glob, etc.)
|
||||
// surfaces via the returned error; the caller decides how to handle it.
|
||||
//
|
||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||
// the InstallAll phase; nil/empty is fine.
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) 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
|
||||
// error once a plugin is present, so reading a malformed yaml here
|
||||
// would let an unrelated broken file on the user's machine abort a
|
||||
// plugin-governed binary -- exactly the file the plugin is supposed
|
||||
// to shadow. Skipping the read keeps the shadow contract honest.
|
||||
var (
|
||||
yamlRules []*platform.Rule
|
||||
yamlPath string
|
||||
)
|
||||
if len(pluginRules) == 0 {
|
||||
p, perr := userPolicyPath()
|
||||
if perr != nil {
|
||||
// No user home dir means we cannot locate the policy. Treat
|
||||
// the same as "file missing": no pruning, no error. This keeps
|
||||
// non-interactive CI environments (no HOME set) running.
|
||||
p = ""
|
||||
}
|
||||
yamlPath = p
|
||||
loaded, lerr := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
||||
if lerr != nil {
|
||||
// Yaml-only failures are fail-OPEN at the caller (warn and
|
||||
// continue), but the active-policy snapshot is process-global
|
||||
// and may still carry data from a previous build in long-lived
|
||||
// embedders / tests. Clear it explicitly so `config policy
|
||||
// show` reports "no policy" instead of a stale rule that
|
||||
// doesn't reflect the current command tree.
|
||||
cmdpolicy.SetActive(nil)
|
||||
return lerr
|
||||
}
|
||||
yamlRules = loaded
|
||||
}
|
||||
|
||||
rules, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
PluginRules: pluginRules,
|
||||
YAMLRules: yamlRules,
|
||||
YAMLPath: yamlPath,
|
||||
})
|
||||
if err != nil {
|
||||
cmdpolicy.SetActive(nil)
|
||||
return err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||
return nil
|
||||
}
|
||||
|
||||
// RuleName attributes a denial to a specific rule in the envelope.
|
||||
// With a single rule that is unambiguous and preserves the legacy
|
||||
// envelope verbatim; with several rules a denial means "no rule
|
||||
// granted it", which has no single owner, so the field is left empty
|
||||
// and reason_code=no_matching_rule carries the meaning instead.
|
||||
ruleName := ""
|
||||
if len(rules) == 1 {
|
||||
ruleName = rules[0].Name
|
||||
}
|
||||
|
||||
engine := cmdpolicy.NewSet(rules)
|
||||
decisions := engine.EvaluateAll(rootCmd)
|
||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName)
|
||||
cmdpolicy.Apply(rootCmd, denied)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rules: rules,
|
||||
Source: source,
|
||||
DeniedPaths: len(denied),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// installPluginsAndHooks runs the InstallAll phase on the globally-
|
||||
// registered plugins, returning the Plugin.Restrict contributions for
|
||||
// cmdpolicy and the populated hook.Registry for the runtime wrapper.
|
||||
// Errors from FailClosed plugins propagate; FailOpen failures are
|
||||
// warned to errOut and the loop continues.
|
||||
func installPluginsAndHooks(errOut io.Writer) (*internalplatform.InstallResult, error) {
|
||||
plugins := platform.RegisteredPlugins()
|
||||
if len(plugins) == 0 {
|
||||
return &internalplatform.InstallResult{Registry: nil}, nil
|
||||
}
|
||||
return internalplatform.InstallAll(plugins, errOut)
|
||||
}
|
||||
|
||||
// recordInventory builds and stores the plugin inventory snapshot for
|
||||
// diagnostic commands (config plugins show) to read at runtime. Called
|
||||
// once from build.go after applyUserPolicyPruning + wireHooks succeed.
|
||||
func recordInventory(installResult *internalplatform.InstallResult) {
|
||||
if installResult == nil {
|
||||
internalplatform.SetActiveInventory(nil)
|
||||
return
|
||||
}
|
||||
pluginSrcs := make([]internalplatform.PluginInventorySource, 0, len(installResult.Plugins))
|
||||
for _, p := range installResult.Plugins {
|
||||
pluginSrcs = append(pluginSrcs, internalplatform.PluginInventorySource{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Capabilities: p.Capabilities,
|
||||
})
|
||||
}
|
||||
ruleSrcs := make([]internalplatform.RuleInventorySource, 0, len(installResult.PluginRules))
|
||||
for _, r := range installResult.PluginRules {
|
||||
if r.Rule == nil {
|
||||
continue
|
||||
}
|
||||
idents := make([]string, len(r.Rule.Identities))
|
||||
for i, id := range r.Rule.Identities {
|
||||
idents[i] = string(id)
|
||||
}
|
||||
ruleSrcs = append(ruleSrcs, internalplatform.RuleInventorySource{
|
||||
PluginName: r.PluginName,
|
||||
Allow: r.Rule.Allow,
|
||||
Deny: r.Rule.Deny,
|
||||
MaxRisk: string(r.Rule.MaxRisk),
|
||||
Identities: idents,
|
||||
RuleName: r.Rule.Name,
|
||||
Desc: r.Rule.Description,
|
||||
AllowUnannotated: r.Rule.AllowUnannotated,
|
||||
})
|
||||
}
|
||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
|
||||
}
|
||||
|
||||
// wireHooks installs Observer/Wrapper hooks onto every runnable command
|
||||
// and emits the Startup lifecycle event. The registry may be nil when
|
||||
// no plugin contributed any hook -- the function short-circuits in
|
||||
// that case to avoid useless RunE wrapping.
|
||||
func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry) error {
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
hook.Install(rootCmd, reg, cobraCommandViewSource{})
|
||||
return hook.Emit(ctx, reg, platform.Startup, nil)
|
||||
}
|
||||
|
||||
// cobraCommandViewSource is the default CommandViewSource: it returns a
|
||||
// live view over the *cobra.Command. Strict-mode's Remove+Add stub
|
||||
// (cmd/prune.go::strictModeStubFrom) explicitly forwards the original
|
||||
// annotations + Short/Long so the live view keeps reporting Risk /
|
||||
// Identities / Domain through the replacement. User-layer policy
|
||||
// (cmdpolicy/apply.go::installDenyStub) mutates in place, preserving
|
||||
// metadata trivially.
|
||||
type cobraCommandViewSource struct{}
|
||||
|
||||
func (cobraCommandViewSource) View(cmd *cobra.Command) platform.CommandView {
|
||||
return cobraCommandView{cmd: cmd}
|
||||
}
|
||||
|
||||
// cobraCommandView adapts *cobra.Command to the CommandView interface.
|
||||
type cobraCommandView struct {
|
||||
cmd *cobra.Command
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Path() string {
|
||||
return cmdpolicy.CanonicalPath(v.cmd)
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Domain() string {
|
||||
for c := v.cmd; c != nil; c = c.Parent() {
|
||||
if c.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if v, ok := c.Annotations["cmdmeta.domain"]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Risk() (platform.Risk, bool) {
|
||||
for c := v.cmd; c != nil; c = c.Parent() {
|
||||
if c.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if r, ok := c.Annotations["risk_level"]; ok && r != "" {
|
||||
return platform.Risk(r), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Identities() []platform.Identity {
|
||||
for c := v.cmd; c != nil; c = c.Parent() {
|
||||
if c.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if raw, ok := c.Annotations["lark:supportedIdentities"]; ok && raw != "" {
|
||||
parts := splitCSV(raw)
|
||||
out := make([]platform.Identity, len(parts))
|
||||
for i, p := range parts {
|
||||
out[i] = platform.Identity(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Annotation(key string) (string, bool) {
|
||||
if v.cmd.Annotations == nil {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.cmd.Annotations[key]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// splitCSV is a tiny csv-without-quotes helper. The
|
||||
// lark:supportedIdentities annotation is always plain
|
||||
// "user" / "bot" / "user,bot" without escaping.
|
||||
func splitCSV(s string) []string {
|
||||
out := []string{}
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == ',' {
|
||||
out = append(out, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
out = append(out, s[start:])
|
||||
return out
|
||||
}
|
||||
|
||||
// userPolicyPath returns the path of <baseConfigDir>/policy.yml.
|
||||
//
|
||||
// The base directory honours LARKSUITE_CLI_CONFIG_DIR (via
|
||||
// core.GetBaseConfigDir) so that test isolation, container deployments
|
||||
// and per-Agent config overrides all see a consistent policy location.
|
||||
// Using vfs.UserHomeDir directly here would silently bypass the env
|
||||
// override and route every test through the real ~/.lark-cli.
|
||||
//
|
||||
// The error return is retained for caller compatibility but is always
|
||||
// nil today: GetBaseConfigDir falls back to a relative ".lark-cli" when
|
||||
// the home dir can't be resolved, and the resolver already treats a
|
||||
// missing file as "no policy".
|
||||
func userPolicyPath() (string, error) {
|
||||
return filepath.Join(core.GetBaseConfigDir(), userPolicyFileName), nil
|
||||
}
|
||||
|
||||
// warnPolicyError writes a one-line stderr warning when the user policy
|
||||
// fails to load. V1 yaml errors are fail-OPEN -- the CLI keeps running
|
||||
// without policy enforcement so the user can fix the typo. Plugin-supplied
|
||||
// rules are fail-CLOSED instead because integrators take a code-level
|
||||
// responsibility for them.
|
||||
//
|
||||
// Wrapped errors may carry the absolute policy path (os.PathError); fold
|
||||
// the home prefix to "~" before emitting so stderr piped into agents /
|
||||
// CI logs does not leak the user's home directory.
|
||||
func warnPolicyError(errOut io.Writer, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(errOut, "warning: user policy not applied: %s\n", redactHome(err.Error()))
|
||||
}
|
||||
|
||||
func redactHome(s string) string {
|
||||
if home, err := vfs.UserHomeDir(); err == nil && home != "" {
|
||||
s = strings.ReplaceAll(s, home, "~")
|
||||
}
|
||||
return s
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user