Compare commits

..

1 Commits

Author SHA1 Message Date
fangshuyu
78283cefbb docs: clarify doc block insert ordering 2026-06-16 11:31:18 +08:00
571 changed files with 7309 additions and 57357 deletions

View File

@@ -10,6 +10,8 @@ on:
permissions:
contents: read
actions: read
checks: write
pull-requests: write
jobs:
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
@@ -78,47 +80,10 @@ 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"
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
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: Run CLI deterministic gate
run: 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 -C lint . ..
coverage:
needs: fast-gate
@@ -138,7 +103,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
@@ -220,7 +184,7 @@ jobs:
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint, deterministic-gate]
needs: [unit-test, lint]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
@@ -241,12 +205,9 @@ jobs:
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint, 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 }}
@@ -293,9 +254,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:
@@ -333,7 +291,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, 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
@@ -345,7 +303,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 | 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
@@ -361,7 +318,6 @@ jobs:
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \
"${{ needs.e2e-dry-run.result }}" \

View File

@@ -1,566 +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.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
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.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.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
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.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.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 });

5
.gitignore vendored
View File

@@ -7,11 +7,6 @@ bin/
# Node
node_modules/
# Python (skill-bundled helper scripts)
__pycache__/
*.py[cod]
*$py.class
# OS
.DS_Store

View File

@@ -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
@@ -49,16 +49,9 @@ linters:
- 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
# Paths that run forbidigo. Add an entry when a path joins one of
# the rules below.
- 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)
linters:
- forbidigo
- path: internal/vfs/
@@ -72,26 +65,31 @@ linters:
- 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.
- 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/)
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
text: errs-no-legacy-helper
linters:
- forbidigo
settings:
depguard:
@@ -110,6 +108,22 @@ linters:
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── legacy output.Err* helpers banned on migrated paths ──
# output.ErrBare is intentionally not listed — it is the predicate-
# command silent-exit signal, outside the typed envelope contract.
- pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on migrated domains ──
# These helpers emit legacy output.Err* / bare error shapes or drop
# typed metadata such as Param/Cause. Migrated domains must use typed
# common replacements or local typed helpers instead.
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy or
metadata-poor error shapes. Use typed common replacements, typed
errs.NewXxxError builders, or domain-local typed helpers.
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-

View File

@@ -2,67 +2,6 @@
All notable changes to this project will be documented in this file.
## [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
@@ -1236,9 +1175,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[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

View File

@@ -5,13 +5,6 @@ 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
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
@@ -22,7 +15,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
all: test
@@ -46,12 +39,6 @@ fmt-check:
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 \
@@ -66,30 +53,7 @@ examples-build:
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))
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) \
--facts-out $(QUALITY_GATE_FACTS_OUT)
test: vet fmt-check unit-test examples-build integration-test
install: build
install -d $(PREFIX)/bin

View File

@@ -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"
@@ -124,13 +123,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 +153,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 +196,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,7 +233,7 @@ 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})
}
@@ -259,7 +243,7 @@ func apiRun(opts *APIOptions) error {
// 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(err)
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -279,7 +263,7 @@ func apiRun(opts *APIOptions) error {
// MarkRaw: see comment above on the DoAPI path. Skips legacy
// *ExitError enrichment; typed errors flow through unchanged.
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
return nil
}
@@ -288,76 +272,46 @@ 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 {
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// 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, ac.CheckResponse); 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(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
return errs.MarkRaw(apiErr)
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(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); 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
}
}

View File

@@ -4,8 +4,6 @@
package api
import (
"context"
"encoding/json"
"errors"
"os"
"sort"
@@ -13,7 +11,6 @@ import (
"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"
@@ -104,19 +101,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 +328,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 +342,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 +354,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 +395,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

View File

@@ -33,9 +33,12 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
}
var bare *output.BareError
var bare *output.ExitError
if !errors.As(err, &bare) {
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err)
}
if bare.Detail != nil {
t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail)
}
if stderr.Len() != 0 {

View File

@@ -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"
@@ -60,7 +59,7 @@ func authListRun(opts *ListOptions) error {
// 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 != "" {

View File

@@ -878,7 +878,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
// 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
// ExitError 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()
@@ -945,13 +945,16 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
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)
// Returned error must be the bare *output.ExitError signal (no envelope).
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if bareErr.Code != output.ExitAuth {
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
if exitErr.Code != output.ExitAuth {
t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth)
}
if exitErr.Detail != nil {
t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail)
}
}

View File

@@ -20,7 +20,6 @@ import (
"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"
@@ -34,13 +33,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.
@@ -80,41 +75,6 @@ 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
@@ -196,26 +156,15 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
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 {
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)

View File

@@ -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
}

View File

@@ -4,7 +4,8 @@
package completion
import (
"github.com/larksuite/cli/errs"
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
@@ -31,9 +32,7 @@ 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])
}
},
}

View File

@@ -212,7 +212,10 @@ func finalizeSource(opts *BindOptions) (string, error) {
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection()
if err != nil {
return "", langSelectionError(err)
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
}
opts.Lang = string(lang)
opts.UILang = lang

View File

@@ -20,29 +20,35 @@ import (
"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. It
// accepts both *output.ExitError (used by output.ErrWithHint) and the
// typed errors (ValidationError, ConfigError) — they normalize to the same
// wantDetail fields. The wantDetail.Type is matched against the typed error's
// Category string ("validation", "config", etc.).
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
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)
}
return
}
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}
gotDetail := output.ErrDetail{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)
}
@@ -53,13 +59,13 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDe
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}
gotDetail := output.ErrDetail{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
}
t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
}
// assertEnvelope decodes stdout and checks it matches want exactly — every key
@@ -173,21 +179,15 @@ func TestConfigBindRun_InvalidLang(t *testing.T) {
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)
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
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())
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
@@ -365,7 +365,7 @@ 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`,
})
@@ -382,7 +382,7 @@ 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{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
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",
@@ -421,7 +421,7 @@ 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{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
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,7 +437,7 @@ 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{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
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,7 +566,7 @@ 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{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
Hint: "verify Hermes is installed and configured at " + envPath,
@@ -584,7 +584,7 @@ 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{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify OpenClaw is installed and configured",
@@ -731,7 +731,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
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",
@@ -750,7 +750,7 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
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{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify lark-channel-bridge is installed and configured",
@@ -770,7 +770,7 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "accounts.app.id missing in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -789,7 +789,7 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "accounts.app.secret is empty in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -835,19 +835,17 @@ 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.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
}
// 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)
@@ -1189,7 +1187,7 @@ 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{
base := output.ErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
}
@@ -1205,7 +1203,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
}
got := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
got, wantWorkFirst, wantPersonalFirst)
@@ -1232,7 +1230,7 @@ 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{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only_one",
@@ -1252,7 +1250,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`,
})
@@ -1538,7 +1536,7 @@ 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{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "FEISHU_APP_ID not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1558,7 +1556,7 @@ 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{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "FEISHU_APP_SECRET not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1584,7 +1582,7 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "openclaw.json missing channels.feishu section",
Hint: "configure Feishu in OpenClaw first",
@@ -1612,7 +1610,7 @@ 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{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
Hint: "configure channels.feishu.appSecret in openclaw.json",
@@ -1674,7 +1672,7 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",

View File

@@ -51,7 +51,7 @@ 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{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",
@@ -64,7 +64,7 @@ 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{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "hermes: no app configured",
})
@@ -100,7 +100,7 @@ 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{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -117,7 +117,7 @@ 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{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -152,7 +152,7 @@ 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{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only",

View File

@@ -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"
@@ -93,16 +92,16 @@ 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.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
}
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)
}
}
@@ -234,21 +233,15 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) {
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)
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
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())
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
@@ -392,38 +385,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)
}
}

View File

@@ -6,11 +6,13 @@ package config
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
@@ -125,9 +127,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.
@@ -178,20 +183,6 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
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)
}
// saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed.
@@ -216,9 +207,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].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{
@@ -260,8 +249,8 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
// 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.
// survive; legacy *output.ExitError also passes through; everything else
// (filesystem, keychain, etc.) is wrapped as InternalError.
func wrapUpdateExistingProfileErr(err error) error {
if err == nil {
return nil
@@ -269,6 +258,10 @@ func wrapUpdateExistingProfileErr(err error) error {
if errs.IsTyped(err) {
return err
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
}
@@ -343,7 +336,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
@@ -360,7 +353,10 @@ func configInitRun(opts *ConfigInitOptions) error {
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
lang, err := promptLangSelection()
if err != nil {
return langSelectionError(err)
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
}
opts.Lang = string(lang)
opts.UILang = lang
@@ -383,7 +379,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
@@ -413,7 +409,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
@@ -518,7 +514,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return wrapSaveConfigError(err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)

View File

@@ -8,7 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
@@ -26,15 +26,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 +48,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")
}
}

View File

@@ -4,14 +4,10 @@
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 {
@@ -101,12 +97,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)
}

View File

@@ -65,8 +65,8 @@ func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t
// 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.
// exit semantics (regression: typed ValidationError was being downgraded to
// InternalError by the legacy *output.ExitError-only passthrough).
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
if got := wrapUpdateExistingProfileErr(nil); got != nil {
@@ -90,6 +90,18 @@ func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T
}
}
func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) {
in := &output.ExitError{Code: 7, Err: errors.New("legacy")}
got := wrapUpdateExistingProfileErr(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got)
}
if exitErr.Code != 7 {
t.Errorf("Code = %d, want 7", exitErr.Code)
}
}
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
in := fmt.Errorf("disk full")
got := wrapUpdateExistingProfileErr(in)

View File

@@ -14,7 +14,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -95,7 +94,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 +108,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
}

View File

@@ -15,6 +15,7 @@ import (
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
@@ -48,6 +49,32 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
authErr.Hint += "\n" + scopeHint
}
// enrichMissingScopeError appends a "current command requires scope(s): X"
// hint to a legacy *output.ExitError when the underlying error carries the
// need_user_authorization marker AND the current command declares scopes
// locally.
//
// Deprecated: enrichment for the legacy envelope; the typed path is
// applyNeedAuthorizationHint above.
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr == nil || exitErr.Detail == nil {
return
}
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
return
}
scopes := resolveDeclaredScopesForCurrentCommand(f)
if len(scopes) == 0 {
return
}
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
if exitErr.Detail.Hint == "" {
exitErr.Detail.Hint = scopeHint
return
}
exitErr.Detail.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.

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -146,28 +146,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
@@ -243,9 +229,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).
@@ -283,66 +266,46 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).
WithMissingScopes(missing...).
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
WithHint("%s", 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)
"EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, strings.Join(missing, ", ")).
WithHint("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).
@@ -386,9 +349,9 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
// 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) {

View File

@@ -270,15 +270,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
}

View File

@@ -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")
}
}

View File

@@ -97,9 +97,9 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
}
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
if !strings.Contains(p.Hint, wantURL) {
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
}
}
@@ -157,8 +157,9 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
}
hint := permErr.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)
}
}

View File

@@ -96,34 +96,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) })

View File

@@ -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)
}
}

View File

@@ -5,10 +5,10 @@ package cmd
import (
"errors"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -40,65 +40,31 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
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)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
if !strings.Contains(exitErr.Detail.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
}
// 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)
detail, _ := exitErr.Detail.Detail.(map[string]any)
valid, _ := detail["valid_flags"].([]string)
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
t.Errorf("valid_flags should list find & range, got %v", valid)
}
}
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)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, 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)
if exitErr.Detail.Type != "flag_error" {
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
}
}

View File

@@ -9,12 +9,10 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -104,7 +102,7 @@ func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Comma
}
// Happy path: a valid policy.yml denies one specific command. The denied
// command's RunE returns a typed error envelope; allowed commands are
// command's RunE returns a typed ExitError envelope; allowed commands are
// untouched.
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
cfgDir := tmpHome(t)
@@ -129,27 +127,13 @@ max_risk: write
if err == nil {
t.Fatalf("+delete-doc RunE should return an error")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// The denial taxonomy (reason_code, layer, rule) is preserved on the
// wrapped *platform.CommandDeniedError cause and folded into the hint.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("error chain should expose *platform.CommandDeniedError")
}
if cd.ReasonCode != "command_denylisted" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want command_denylisted", cd.ReasonCode)
}
if !strings.Contains(verr.Hint, "command_denylisted") {
t.Errorf("hint should surface reason_code command_denylisted, got %q", verr.Hint)
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok || detail["reason_code"] != "command_denylisted" {
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
}
// im/+send must be denied (domain not in Allow).

View File

@@ -8,9 +8,9 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
internalplatform "github.com/larksuite/cli/internal/platform"
)
@@ -34,8 +34,16 @@ import (
// lands directly on their RunE, which now carries the guard.
//
// makeErr is called for every guarded dispatch; it must return a fresh
// typed error each time.
func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
// *output.ExitError each time (the envelope writer mutates a few fields
// as it serialises).
// Deprecated: installFatalGuard accepts a *output.ExitError-producing lambda,
// which is part of the legacy error surface that predates the typed error
// contract introduced by errs/. New code MUST NOT add new callers — the
// platform-extension fatal-guard plumbing will switch to typed errs.* errors
// when the platform-extension framework migrates. This wrapper is retained
// only for the existing in-tree call sites; it will be removed once they
// have moved to the typed surface.
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
// Two cobra subcommands are injected lazily at Execute() time and
// would otherwise slip past walkGuard. We pre-register both so
// walkGuard catches them.
@@ -72,65 +80,120 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
}
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
// failure as a typed validation error (failed_precondition) before any
// command runs.
// failure as a structured plugin_install envelope before any command
// runs.
// Deprecated: installPluginInstallErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin install failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
makeErr := func() error {
makeErr := func() *output.ExitError {
var pi *internalplatform.PluginInstallError
if errors.As(installErr, &pi) {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", pi.Error()).
WithHint("plugin %q failed to install (reason_code %s); fix or remove the plugin before running commands", pi.PluginName, pi.ReasonCode).
WithCause(installErr)
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: pi.Error(),
Detail: map[string]any{
"plugin": pi.PluginName,
"reason_code": pi.ReasonCode,
"reason": pi.Reason,
},
},
Err: installErr,
}
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: installErr.Error(),
Detail: map[string]any{
"reason_code": internalplatform.ReasonInstallFailed,
},
},
Err: installErr,
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", installErr.Error()).
WithHint("a plugin failed to install (reason_code %s); fix or remove the plugin before running commands", internalplatform.ReasonInstallFailed).
WithCause(installErr)
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
// error (single plugin invalid Rule or multiple plugins each contributing
// Restrict). The hint separates the two failure modes by reason code:
// Restrict). The design separates the envelope type:
//
// - "invalid_rule" - single bad rule
// - "multiple_restrict_plugins" - multiple Restrict plugins conflict
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
//
// Either way the CLI must NOT silently continue with a broken policy.
// Deprecated: installPluginConflictGuard produces a legacy *output.ExitError
// via its internal makeErr lambda. New code MUST NOT add such producers —
// plugin conflict failures should surface as a typed *errs.XxxError once the
// platform-extension framework migrates. This helper is retained only while
// existing call sites are migrated; it will be removed once they have moved
// to the typed surface.
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
makeErr := func() error {
makeErr := func() *output.ExitError {
envelopeType := "plugin_install"
reasonCode := internalplatform.ReasonInvalidRule
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
envelopeType = "plugin_conflict"
reasonCode = internalplatform.ReasonMultipleRestricts
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithHint("plugin policy configuration is broken (reason_code %s); fix the plugin's Restrict rule or remove the conflicting plugin", reasonCode).
WithCause(err)
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: envelopeType,
Message: err.Error(),
Detail: map[string]any{
"reason_code": reasonCode,
},
},
Err: err,
}
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
// failure as a typed validation error (failed_precondition). The hint's
// reason code splits returned-error vs panic so consumers (audit /
// on-call) can tell the two failure modes apart.
// failure as a plugin_lifecycle envelope. The reason_code splits
// returned-error vs panic so consumers (audit / on-call) can tell the
// two failure modes apart.
// Deprecated: installPluginLifecycleErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin lifecycle failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
makeErr := func() error {
makeErr := func() *output.ExitError {
reasonCode := "lifecycle_failed"
hookName := ""
detail := map[string]any{
"reason_code": reasonCode,
}
var le *hook.LifecycleError
if errors.As(err, &le) {
if le.Panic {
reasonCode = "lifecycle_panic"
}
hookName = le.HookName
detail = map[string]any{
"reason_code": reasonCode,
"hook_name": le.HookName,
"event": "startup",
}
}
typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithCause(err)
if hookName != "" {
return typed.WithHint("plugin startup hook %q failed (reason_code %s); fix or remove the plugin before running commands", hookName, reasonCode)
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_lifecycle",
Message: err.Error(),
Detail: detail,
},
Err: err,
}
return typed.WithHint("a plugin startup hook failed (reason_code %s); fix or remove the plugin before running commands", reasonCode)
}
installFatalGuard(rootCmd, makeErr)
}
@@ -156,7 +219,14 @@ func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
//
// This way the very first non-nil step in cobra's chain is always our
// guard, regardless of which leaf the user invoked.
func walkGuard(cmd *cobra.Command, makeErr func() error) {
// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part
// of the legacy error surface that predates the typed error contract
// introduced by errs/. New code MUST NOT add new callers — the platform-
// extension guard plumbing will switch to typed errs.* errors when the
// platform-extension framework migrates. This wrapper is retained only for
// the existing in-tree call sites; it will be removed once they have moved
// to the typed surface.
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
if cmd == nil {
return
}

View File

@@ -6,14 +6,12 @@ package cmd
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
@@ -34,7 +32,7 @@ func (failClosedAbortingPlugin) Install(platform.Registrar) error {
}
// When a FailClosed plugin fails to install, buildInternal must
// install a PersistentPreRunE that returns a typed *errs.ValidationError.
// install a PersistentPreRunE that returns a structured *output.ExitError.
// The user must NEVER see a silent partial-install state.
//
// This pins the build.go fix for codex's NEW ISSUE about
@@ -95,31 +93,26 @@ func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
checkGuardError(t, leaf.RunE(leaf, nil))
}
// checkGuardError asserts that err is the typed validation error the
// install guard produces: a failed_precondition *errs.ValidationError
// (exit 2) whose message + hint preserve the plugin name and the
// install_failed reason code (the recovery info that lived in the legacy
// detail map).
// checkGuardError asserts that err is the structured plugin_install
// ExitError the guard produces.
func checkGuardError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
detail := exitErr.Detail.Detail.(map[string]any)
if detail["plugin"] != "policy" {
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
}
if !strings.Contains(verr.Hint, "policy") {
t.Errorf("hint should name the failing plugin %q, got %q", "policy", verr.Hint)
}
if !strings.Contains(verr.Hint, internalplatform.ReasonInstallFailed) {
t.Errorf("hint should surface reason_code %q, got %q", internalplatform.ReasonInstallFailed, verr.Hint)
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
}
}

View File

@@ -8,13 +8,11 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -158,23 +156,19 @@ func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
}
err = leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
detail := exitErr.Detail.Detail.(map[string]any)
if detail["reason_code"] != "aborted" {
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
}
// The namespaced hook name and the abort semantics are preserved in the
// message so a caller can identify which plugin hook rejected the call.
if !strings.Contains(verr.Message, "policy-plugin.policy") {
t.Errorf("message should name the aborting hook policy-plugin.policy, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "aborted") {
t.Errorf("message should describe the abort, got %q", verr.Message)
if detail["hook_name"] != "policy-plugin.policy" {
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
}
// errors.As must still reach the original AbortError so consumers
@@ -415,20 +409,15 @@ func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "plugin_conflict" {
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// reason_code multiple_restrict_plugins is folded into the hint so the
// operator can distinguish a multi-Restrict conflict from a bad rule.
if !strings.Contains(verr.Hint, "multiple_restrict_plugins") {
t.Errorf("hint should surface reason_code multiple_restrict_plugins, got %q", verr.Hint)
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
}
}
@@ -458,20 +447,15 @@ func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// reason_code invalid_rule is folded into the hint, distinct from the
// multiple_restrict_plugins conflict path.
if !strings.Contains(verr.Hint, "invalid_rule") {
t.Errorf("hint should surface reason_code invalid_rule, got %q", verr.Hint)
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
t.Errorf("reason_code = %v, want invalid_rule", rc)
}
}
@@ -500,24 +484,19 @@ func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "plugin_lifecycle" {
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "lifecycle_failed" {
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
}
// reason_code lifecycle_failed (vs lifecycle_panic) and the failing
// hook name are folded into the hint so audit / on-call can tell the
// failure mode and which hook failed.
if !strings.Contains(verr.Hint, "lifecycle_failed") {
t.Errorf("hint should surface reason_code lifecycle_failed, got %q", verr.Hint)
}
if !strings.Contains(verr.Hint, "lc.start") {
t.Errorf("hint should name the failing hook lc.start, got %q", verr.Hint)
if d["hook_name"] != "lc.start" {
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
}
}
@@ -541,20 +520,12 @@ func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
}
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// A panicking startup hook is distinguished from a returned error by
// reason_code lifecycle_panic in the hint.
if !strings.Contains(verr.Hint, "lifecycle_panic") {
t.Errorf("hint should surface reason_code lifecycle_panic, got %q", verr.Hint)
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
}
}
@@ -608,24 +579,19 @@ func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
}()
err = leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
}
// The recovered panic surfaces as a structured error naming the
// namespaced hook (p.boom) and describing the panic, so the process
// never crashes and the caller can attribute the failure.
if !strings.Contains(verr.Message, "p.boom") {
t.Errorf("message should name the namespaced hook p.boom, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "panic") {
t.Errorf("message should describe the panic, got %q", verr.Message)
if d["hook_name"] != "p.boom" {
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
}
}
@@ -687,24 +653,19 @@ func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
}()
err = leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
}
// A panic in the wrapper FACTORY (not just the inner handler) is
// recovered into the same structured panic error, naming the
// namespaced hook fac.bad-factory.
if !strings.Contains(verr.Message, "fac.bad-factory") {
t.Errorf("message should name the namespaced hook fac.bad-factory, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "panic") {
t.Errorf("message should describe the panic, got %q", verr.Message)
if d["hook_name"] != "fac.bad-factory" {
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
}
}

View File

@@ -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/i18n"
@@ -54,9 +53,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
if err := core.ValidateProfileName(name); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).
WithCause(err).
WithParam("--name")
return output.ErrValidation("%v", err)
}
langPref, err := cmdutil.ParseLangFlag(lang)
@@ -67,57 +64,46 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
// Read secret from stdin
if !appSecretStdin {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret must be provided via stdin").
WithHint("use --app-secret-stdin and pipe the secret").
WithParam("--app-secret-stdin")
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
}
scanner := bufio.NewScanner(f.IOStreams.In)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to read secret from stdin: %v", err).
WithCause(err).
WithParam("--app-secret-stdin")
return output.ErrValidation("failed to read secret from stdin: %v", err)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret").
WithHint("pipe the app secret to stdin").
WithParam("--app-secret-stdin")
return output.ErrValidation("stdin is empty, expected app secret")
}
appSecret := strings.TrimSpace(scanner.Text())
if appSecret == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty").
WithHint("pipe a non-empty app secret to stdin").
WithParam("--app-secret-stdin")
return output.ErrValidation("app secret read from stdin is empty")
}
// Load or create config
multi, err := core.LoadMultiAppConfig()
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to load config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err)
}
multi = &core.MultiAppConfig{}
}
// Check name uniqueness
if multi.FindApp(name) != nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", name).
WithHint("choose a different name, or remove the existing profile first").
WithParam("--name")
return output.ErrValidation("profile %q already exists", name)
}
// Check app-id uniqueness — keychain stores secrets by appId, so
// multiple profiles sharing the same appId would collide on credentials.
for _, a := range multi.Apps {
if a.AppId == appID {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName()).
WithParam("--app-id")
return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName())
}
}
// Store secret securely
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "%v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
parsedBrand := core.ParseBrand(brand)
@@ -148,7 +134,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
}
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)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))

View File

@@ -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"
@@ -46,7 +45,7 @@ func profileListRun(f *cmdutil.Factory) error {
output.PrintJson(f.IOStreams.Out, []profileListItem{})
return nil
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to load config: %v", err).WithCause(err)
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
}
if multi == nil || len(multi.Apps) == 0 {
output.PrintJson(f.IOStreams.Out, []profileListItem{})

View File

@@ -11,7 +11,6 @@ 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"
@@ -51,16 +50,6 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
if !strings.Contains(err.Error(), "failed to load config") {
t.Fatalf("error = %v, want failed to load config", err)
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
}
if internalErr.Subtype != errs.SubtypeFileIO {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
}
// TestProfileAddRun_Lang covers the unified --lang contract on profile add:
@@ -106,9 +95,9 @@ func TestProfileAddRun_Lang(t *testing.T) {
if err == nil {
t.Fatal("expected validation error for --lang ZH, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) || output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("expected typed validation error with ExitValidation, got %T: %v", err, err)
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Code != output.ExitValidation {
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
}
})
}
@@ -417,226 +406,17 @@ func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
t.Helper()
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
}
if internalErr.Subtype != errs.SubtypeStorage {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeStorage)
if exitErr.Code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
}
if internalErr.Cause == nil {
t.Fatalf("cause = nil, want wrapped underlying error")
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
}
if !strings.Contains(internalErr.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", internalErr.Message, wantMsg)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
}
// assertValidationError asserts err is a typed *errs.ValidationError with the
// given subtype, message fragment, and exit code 2.
func assertValidationError(t *testing.T, err error, wantSubtype errs.Subtype, wantMsg string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
}
if valErr.Subtype != wantSubtype {
t.Fatalf("subtype = %q, want %q", valErr.Subtype, wantSubtype)
}
if !strings.Contains(valErr.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", valErr.Message, wantMsg)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
return valErr
}
func saveTwoProfiles(t *testing.T) {
t.Helper()
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestProfileAddRun_ValidationErrors(t *testing.T) {
t.Run("invalid profile name", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "bad name!", "app-x", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
if valErr.Param != "--name" {
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
}
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped validation error")
}
})
t.Run("missing app-secret-stdin flag", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileAddRun(f, "p", "app-x", false, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret must be provided via stdin")
if valErr.Param != "--app-secret-stdin" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
}
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
t.Run("empty stdin", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("")
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "stdin is empty")
if valErr.Param != "--app-secret-stdin" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
}
})
t.Run("blank secret on stdin", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader(" \n")
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
})
t.Run("duplicate profile name", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "default", "app-new", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "default" already exists`)
if valErr.Param != "--name" {
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
}
})
t.Run("duplicate app-id", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "fresh", "app-default", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "already used by profile")
if valErr.Param != "--app-id" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-id")
}
})
}
func TestProfileUseRun_ValidationErrors(t *testing.T) {
t.Run("no previous profile for toggle", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileUseRun(f, "-")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "no previous profile to switch back to")
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
t.Run("profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileUseRun(f, "ghost")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
}
func TestProfileRenameRun_ValidationErrors(t *testing.T) {
t.Run("invalid new name", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "default", "bad name!")
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped validation error")
}
})
t.Run("old profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "ghost", "fresh")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
t.Run("new name already exists", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "default", "target")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "target" already exists`)
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
}
func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
t.Run("profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRemoveRun(f, "ghost")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
t.Run("cannot remove the only profile", func(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "solo",
Apps: []core.AppConfig{
{Name: "solo", AppId: "app-solo", AppSecret: core.PlainSecret("secret-solo"), Brand: core.BrandFeishu},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRemoveRun(f, "solo")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "cannot remove the only profile")
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
}
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
dir := setupProfileConfigDir(t)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileListRun(f)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "failed to load config")
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped load error")
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
}
}

View File

@@ -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"
@@ -41,12 +40,11 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
idx := multi.FindAppIndex(name)
if idx < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
if len(multi.Apps) == 1 {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile").
WithHint("add another profile first: lark-cli profile add")
return output.ErrValidation("cannot remove the only profile")
}
app := &multi.Apps[idx]
@@ -67,7 +65,7 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
}
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)
}
// Best-effort credential cleanup after config commit

View File

@@ -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/core"
"github.com/larksuite/cli/internal/output"
@@ -31,7 +30,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
if err := core.ValidateProfileName(newName); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
return output.ErrValidation("%v", err)
}
multi, err := core.LoadOrNotConfigured()
@@ -41,7 +40,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
idx := multi.FindAppIndex(oldName)
if idx < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
}
// Check new name uniqueness across other profiles, allowing renames to this
@@ -51,8 +50,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
continue
}
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", newName).
WithHint("choose a different name")
return output.ErrValidation("profile %q already exists", newName)
}
}
@@ -68,7 +66,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
}
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)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))

View File

@@ -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/core"
"github.com/larksuite/cli/internal/output"
@@ -41,15 +40,14 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
// Handle "-" for toggle-back
if name == "-" {
if multi.PreviousApp == "" {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "no previous profile to switch back to").
WithHint("switch to a profile by name first: lark-cli profile use <name>")
return output.ErrValidation("no previous profile to switch back to")
}
name = multi.PreviousApp
}
app := multi.FindApp(name)
if app == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
targetName := app.ProfileName()
@@ -68,7 +66,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
multi.CurrentApp = targetName
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)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))

View File

@@ -9,10 +9,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// pruneForStrictMode removes commands incompatible with the active strict mode.
@@ -65,10 +65,10 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
// pick auth's instead of our denial. A leaf-level no-op makes
// cobra stop here and proceed to the wrapped RunE.
//
// strict-mode keeps its short Message + independent Hint and wraps
// the CommandDeniedError as the Cause by hand; BuildDenialError
// would override Message with the CommandDeniedError.Error() long
// form.
// strict-mode keeps its short Message + independent Hint and
// composes the shared detail.* / wrapped-CommandDeniedError shape
// by hand; BuildDenialError would override Message with the
// CommandDeniedError.Error() long form.
stubMessage := fmt.Sprintf(
"strict mode is %q, only %s-identity commands are available",
mode, mode.ForcedIdentity())
@@ -105,9 +105,20 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
},
RunE: func(c *cobra.Command, _ []string) error {
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
WithCause(cd)
// Legacy *output.ExitError producer: this literal predates the
// typed error contract introduced by errs/. New denial sites MUST
// NOT construct *output.ExitError directly — they should return a
// typed *errs.XxxError once the cmdpolicy framework migrates.
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "command_denied",
Message: stubMessage,
Hint: stubHint,
Detail: cmdpolicy.DenialDetailMap(cd),
},
Err: cd,
}
},
}
}

View File

@@ -8,7 +8,6 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -248,12 +247,9 @@ func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
}
}
// Pins the strict-mode typed envelope: a failed_precondition
// *errs.ValidationError (exit 2) carrying the short historical Message,
// a Hint that still surfaces the policy layer + reason code (the
// safety-critical recovery info that lived in the legacy detail map),
// and the wrapped *platform.CommandDeniedError so external agents can
// still inspect the structured denial taxonomy via errors.As.
// Pins the strict-mode envelope shape: structured detail.* / wrapped
// CommandDeniedError for external agents, AND the historical short
// Message + independent Hint for existing consumers.
func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
@@ -266,33 +262,30 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
t.Fatalf("strict-mode stub RunE should return error")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("err is not *errs.ValidationError: %T", err)
var ee *output.ExitError
if !errors.As(err, &ee) {
t.Fatalf("err is not *output.ExitError: %T", err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if ee.Detail == nil {
t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON")
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
if ee.Detail.Type != "command_denied" {
t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type)
}
// Short historical Message is preserved verbatim.
if verr.Message != `strict mode is "bot", only bot-identity commands are available` {
t.Errorf("Message = %q, want short historical form", verr.Message)
dm, ok := ee.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail)
}
// The denial layer + reason code remain user-readable in the hint, and
// the historical switch-policy guidance is still appended.
if !strings.Contains(verr.Hint, cmdpolicy.LayerStrictMode) {
t.Errorf("Hint = %q, want substring %q (policy layer)", verr.Hint, cmdpolicy.LayerStrictMode)
if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode {
t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode)
}
if !strings.Contains(verr.Hint, "identity_not_supported") {
t.Errorf("Hint = %q, want substring identity_not_supported (reason code)", verr.Hint)
if got, _ := dm["reason_code"].(string); got != "identity_not_supported" {
t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got)
}
if !strings.Contains(verr.Hint, "if the user explicitly wants to switch policy") {
t.Errorf("Hint = %q, want historical switch-policy guidance", verr.Hint)
if got, _ := dm["policy_source"].(string); got != "strict-mode" {
t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got)
}
// The structured denial taxonomy survives on the wrapped cause.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("err does not unwrap to *platform.CommandDeniedError")
@@ -303,12 +296,15 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
if cd.ReasonCode != "identity_not_supported" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode)
}
if cd.PolicySource != "strict-mode" {
t.Errorf("CommandDeniedError.PolicySource = %q, want strict-mode", cd.PolicySource)
}
if !strings.Contains(cd.Reason, `strict mode is "bot"`) {
t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason)
}
if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` {
t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message)
}
if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") {
t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint)
}
}
// strictModeStubFrom must write the denial annotations so the hook

View File

@@ -13,12 +13,17 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
@@ -212,37 +217,56 @@ func configureFlagCompletions(args []string) {
// and returns the process exit code.
//
// Dispatch order:
// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError):
// render via the typed envelope writer, which lifts extension fields
// (missing_scopes, console_url, challenge_url, ...) to the top level.
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are
// constructed typed at their origin (internal/auth, internal/core), so the
// dispatcher no longer promotes any legacy shape here.
// 2. PartialFailure / BareError signals: the result envelope is already on
// stdout; honor the exit code and write nothing to stderr.
// 3. Residual cobra usage errors (missing required flag, unknown command,
// argument validation): typed as an invalid_argument envelope (exit 2),
// matching the explicit flag/subcommand guards. Flag parse errors are
// already typed upstream by the root FlagErrorFunc.
// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError)
// are promoted via errcompat to their typed errs/ counterparts, with the
// original preserved in the Cause chain.
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the
// typed envelope writer, which lifts extension fields (missing_scopes,
// console_url, challenge_url, ...) to the top level. Routed by
// errs.CategoryOf via ExitCodeOf.
// 3. Legacy *output.ExitError: asExitError adapts it to the legacy
// envelope, written via WriteErrorEnvelope.
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
// Promote legacy error shapes into typed errs/ before envelope marshal.
// NeedAuthorizationError check is first because it is the more specific
// shape; *core.ConfigError check follows. errors.As preserves the original
// in the Cause chain, so external errors.As(&core.ConfigError{}) consumers
// (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match.
//
// Outer-typed short-circuit: if err is already a typed *errs.* error,
// skip PromoteXxxError so the producer's Subtype / Hint / extension
// fields are not overwritten by a coarser promoted shape derived from a
// legacy error buried in its Cause chain. Promotion is only for legacy
// untyped entry points.
if !isOuterTypedError(err) {
var needAuthErr *internalauth.NeedAuthorizationError
if errors.As(err, &needAuthErr) {
err = errcompat.PromoteAuthError(needAuthErr)
} else {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
err = errcompat.PromoteConfigError(cfgErr)
}
}
}
// When the typed error is a need_user_authorization signal, fold in the
// current command's declared scopes as a Hint so the user/AI sees the
// concrete scope(s) to re-auth with. The hint is computed on the fly from
// local shortcut/service metadata — it never depends on server state.
if !errs.IsRaw(err) {
applyNeedAuthorizationHint(f, err)
}
applyNeedAuthorizationHint(f, err)
// Staged dispatch: capture the typed exit code BEFORE attempting the
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
// (partial-write still returns true) so the exit code we read here is
// preserved even if stderr is torn — torn stderr must not downgrade
// typed exits 3/4/6/10 to the plain "Error:" path with exit 1.
// typed exits 3/4/6/10 to the legacy "Error:" path with exit 1.
// WriteTypedErrorEnvelope still returns false when err carries no
// Problem; in that case we fall through to the signal / plain-text paths.
// Problem; in that case we fall through to the legacy bridge below.
typedExit := output.ExitCodeOf(err)
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
return typedExit
@@ -255,63 +279,58 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return pfErr.Code
}
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`):
// stdout already carries the result; honor the requested exit code and
// write nothing to stderr.
var bareErr *output.BareError
if errors.As(err, &bareErr) {
return bareErr.Code
}
// Errors reaching here are untyped: every RunE returns a typed errs.* error
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
// is either a cobra usage mistake (missing required flag, unknown command,
// wrong arg count), which cobra surfaces as a plain error identified by its
// stable text — the same external contract unknownFlagName relies on — or an
// untyped error that leaked past the typed boundary. Classify the former as
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
// internal fault (exit 5) rather than blaming the user's input. The message
// is preserved either way, and the typed envelope still carries any pending
// deprecation notice.
var fallback error
if isCobraUsageError(err) {
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
} else {
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
}
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
return output.ExitCodeOf(fallback)
}
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
// not a typed value we can match on, so the dispatcher recognizes them by text;
// this is the same external contract unknownFlagName already depends on. A
// residual error matching none of these has leaked the typed boundary and is
// treated as an internal fault, not a user error.
var cobraUsageErrorMarkers = []string{
"unknown command ",
"unknown flag: ",
"unknown shorthand",
"required flag(s) ",
"flag needs an argument",
"bad flag syntax:",
"no such flag ",
"invalid argument ",
"arg(s), ", // accepts / requires N arg(s), received / only received M
}
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
// identified by the stable error text of the pinned cobra version.
func isCobraUsageError(err error) bool {
msg := err.Error()
for _, m := range cobraUsageErrorMarkers {
if strings.Contains(msg, m) {
return true
if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw {
// Raw errors (e.g. from `api` command via output.MarkRaw)
// preserve the original API error detail; skip enrichment
// which would clear it.
enrichMissingScopeError(f, exitErr)
enrichPermissionError(f, exitErr)
}
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
return exitErr.Code
}
return false
// A backward-compat alias records its deprecation notice in PreRunE, which
// runs before cobra's required-flag validation — but a missing required flag
// fails before RunE and lands here, where the bare "Error:" line would drop
// the notice. When a deprecation is pending, route through the structured
// envelope so the migration hint still reaches the caller; all other errors
// keep the existing plain output.
if deprecation.GetPending() != nil {
output.WriteErrorEnvelope(errOut, &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
}, string(f.ResolvedIdentity))
return 1
}
fmt.Fprintln(errOut, "Error:", err)
return 1
}
// isOuterTypedError returns true if err is a typed *errs.* error AT THE
// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError
// to gate PromoteXxxError so a producer's outer typed envelope is never
// overwritten by a coarser shape derived from its legacy Cause.
func isOuterTypedError(err error) bool {
_, ok := err.(errs.TypedError)
return ok
}
// asExitError converts known structured error types to *output.ExitError.
// Returns nil for unrecognized errors (e.g. cobra flag errors).
//
// Deprecated: legacy *output.ExitError bridge.
func asExitError(err error) *output.ExitError {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return exitErr
}
return nil
}
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
@@ -342,10 +361,13 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
}
}
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
// with a typed *errs.ValidationError: a flag that belongs to a missing
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
// each fail structured (exit 2) instead of degrading to help + exit 0.
// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// add producers of this shape — unknown-subcommand signals should move to
// a typed *errs.ValidationError (or a dedicated typed error) carrying the
// agent-protocol metadata as typed extension fields. This helper is retained
// only while existing dispatch sites are migrated; it will be removed once
// they have moved to the typed surface.
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
@@ -361,13 +383,28 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
return cmd.Help()
}
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()).
WithHint("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range unknown {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "unknown flag before a subcommand"})
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
// Keep the same detail keys as flagDidYouMean's unknown_flag
// so a consumer keyed on Type can read a stable shape. The
// subcommand isn't resolved here, so suggestions/valid_flags
// have no meaningful universe to draw from — emit empty
// rather than the group's own (misleading) flags. unknown is
// the back-compat singular field; unknown_flags carries the
// full list when more than one flag was supplied.
"unknown": strings.Join(unknown, ", "),
"unknown_flags": unknown,
"command_path": cmd.CommandPath(),
"suggestions": []string{},
"valid_flags": []string{},
},
},
}
return verr
}
// The remaining flags are all defined somewhere in the tree. Those valid
// on the group itself or inherited (e.g. the global --profile) do not
@@ -379,13 +416,19 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(misplaced) == 0 {
return cmd.Help()
}
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")).
WithHint("run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range misplaced {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "flag belongs to a subcommand, not the group"})
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "missing_subcommand",
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
"command_path": cmd.CommandPath(),
"flags": misplaced,
"suggestions": []string{},
},
},
}
return verr
}
unknown := args[0]
available, deprecated := availableSubcommandNames(cmd)
@@ -399,12 +442,27 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
strings.Join(suggestions, ", "), cmd.CommandPath())
}
// Record the offending subcommand and its ranked candidates as a param with
// machine-readable Suggestions so an agent can retry without parsing the
// hint; the hint carries the same candidates as prose.
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
WithParams(errs.InvalidParam{Name: unknown, Reason: "unknown subcommand", Suggestions: suggestions}).
WithHint("%s", hint)
detail := map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
}
// Only services with backward-compat aliases (currently sheets) carry a
// deprecated bucket; omit the key elsewhere so every other service's
// envelope is unchanged.
if len(deprecated) > 0 {
detail["deprecated"] = deprecated
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: detail,
},
}
}
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
@@ -530,34 +588,47 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
// nothing) and the offending flag in `params`. Other flag errors stay typed
// but generic.
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
// --find, where edit distance alone finds nothing). Other flag errors stay
// structured but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "flag_error",
Message: ferr.Error(),
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
},
}
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
// The ranked candidates ride on the param as machine-readable Suggestions so
// an agent can retry without parsing the hint; the hint carries the same
// candidates as prose. The full valid-flag list stays recoverable via --help.
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint)
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
Hint: hint,
Detail: map[string]any{
"unknown": "--" + name,
"command_path": c.CommandPath(),
"suggestions": suggestions,
"valid_flags": valid,
},
},
}
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
@@ -627,3 +698,56 @@ func installTipsHelpFunc(root *cobra.Command) {
}
})
}
// enrichPermissionError rewrites the legacy *output.ExitError envelope so its
// Message + Hint match the per-subtype canonical text produced by the typed
// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint).
// This guarantees a caller observing the wire envelope cannot tell whether
// the error reached the dispatcher via the legacy *ExitError bridge or via
// the typed *errs.PermissionError fast path.
//
// Deprecated: legacy *output.ExitError enrichment; typed PermissionError
// values produced by errclass.BuildAPIError already carry MissingScopes +
// ConsoleURL directly.
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil {
return
}
// Only the legacy permission-class envelope types route here. "app_status"
// covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission"
// covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027).
if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" {
return
}
larkCode := exitErr.Detail.Code
meta, ok := errclass.LookupCodeMeta(larkCode)
if !ok || meta.Category != errs.CategoryAuthorization {
return
}
// Extract required scopes from API error detail (shared helper). May be
// empty for app-status codes — canonical message + hint still apply.
missing := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
cfg, err := f.Config()
if err != nil {
return
}
// Reuse the same console URL builder as the typed path so both wire
// envelopes carry identical console_url values for the same input.
consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing)
// Clear raw API detail — useful info is now in message/hint/console_url.
exitErr.Detail.Detail = nil
identity := string(f.ResolvedIdentity)
if identity == "" {
identity = "user"
}
exitErr.Detail.Message = errclass.CanonicalPermissionMessage(meta.Subtype, cfg.AppID, missing, exitErr.Detail.Message)
exitErr.Detail.Hint = errclass.PermissionHint(missing, identity, meta.Subtype, consoleURL)
exitErr.Detail.ConsoleURL = consoleURL
}

View File

@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"os"
"reflect"
"strings"
"testing"
@@ -26,12 +27,12 @@ import (
"github.com/spf13/cobra"
)
// Canonical strict-mode envelope messages shared across fixtures. The
// switch-policy hint text is asserted by substring in
// assertStrictModeDenialEnvelope.
// Canonical strict-mode envelope strings shared across fixtures
// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
const (
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
strictModeUserMessage = `strict mode is "user", only user-identity commands are available`
strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
)
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
@@ -62,46 +63,37 @@ func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Com
return 0
}
// typedErrorEnvelope mirrors the typed wire shape produced by
// WriteTypedErrorEnvelope: the inner error marshals an errs.Problem
// directly, so "type" is the category, "subtype" is top-level, and there
// is no nested "detail" object. Recovery info (policy source, reason
// code, suggestions) is folded into "hint".
type typedErrorEnvelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Error struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
Message string `json:"message"`
Hint string `json:"hint"`
Param string `json:"param,omitempty"`
} `json:"error"`
}
// parseTypedEnvelope decodes stderr as the typed envelope and fails if the
// legacy nested "detail" object is present (the migration removed it).
func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
t.Helper()
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var raw map[string]any
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
t.Fatalf("failed to parse stderr as JSON: %v\nstderr: %s", err, stderr.String())
}
if errObj, ok := raw["error"].(map[string]any); ok {
if _, hasDetail := errObj["detail"]; hasDetail {
t.Errorf("typed envelope must not carry a nested 'detail' object, got: %s", stderr.String())
}
}
var env typedErrorEnvelope
var env output.ErrorEnvelope
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
t.Fatalf("failed to parse stderr as typed envelope: %v\nstderr: %s", err, stderr.String())
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
}
return env
}
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
// expected ErrorEnvelope exactly via reflect.DeepEqual.
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
t.Helper()
if code != wantCode {
t.Errorf("exit code: got %d, want %d", code, wantCode)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
got := parseEnvelope(t, stderr)
if !reflect.DeepEqual(got, want) {
gotJSON, _ := json.MarshalIndent(got, "", " ")
wantJSON, _ := json.MarshalIndent(want, "", " ")
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
}
}
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
@@ -213,71 +205,23 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
// auth login is user-only, so it gets pruned in strict-mode-bot and the
// stub error fires (not login.go's inline check, which is shadowed by
// pruning). The typed envelope is a failed_precondition validation
// error (exit 2); the strict-mode layer + reason code are folded into
// the hint.
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
}
// assertStrictModeDenialEnvelope pins the shared strict-mode denial shape:
// a validation/failed_precondition envelope whose message is the short
// historical strict-mode line and whose hint still names the strict_mode
// layer + identity_not_supported reason code (the safety-critical recovery
// info), plus the historical switch-policy guidance.
func assertStrictModeDenialEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
t.Helper()
if env.OK {
t.Errorf("envelope ok = true, want false")
}
if env.Error.Type != "validation" {
t.Errorf("error.type = %q, want validation", env.Error.Type)
}
if env.Error.Subtype != "failed_precondition" {
t.Errorf("error.subtype = %q, want failed_precondition", env.Error.Subtype)
}
if env.Error.Message != wantMessage {
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
}
if !strings.Contains(env.Error.Hint, "strict_mode") {
t.Errorf("error.hint = %q, want substring strict_mode (policy layer)", env.Error.Hint)
}
if !strings.Contains(env.Error.Hint, "identity_not_supported") {
t.Errorf("error.hint = %q, want substring identity_not_supported (reason code)", env.Error.Hint)
}
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
t.Errorf("error.hint = %q, want historical switch-policy guidance", env.Error.Hint)
}
}
// assertCheckStrictModeEnvelope pins the typed envelope produced by
// cmdutil.Factory.CheckStrictMode (the identity-guard path for explicit
// --as on shortcuts / service methods / api): a *errs.ValidationError with
// subtype invalid_argument, the canonical strict-mode message, and the
// switch-policy hint.
func assertCheckStrictModeEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
t.Helper()
if env.OK {
t.Errorf("envelope ok = true, want false")
}
if env.Error.Type != "validation" {
t.Errorf("error.type = %q, want validation", env.Error.Type)
}
if env.Error.Subtype != "invalid_argument" {
t.Errorf("error.subtype = %q, want invalid_argument", env.Error.Subtype)
}
if env.Error.Message != wantMessage {
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
}
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
t.Errorf("error.hint = %q, want switch-policy guidance", env.Error.Hint)
}
// pruning).
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "auth/login",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
},
})
}
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
@@ -288,14 +232,22 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/+messages-search",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
},
})
}
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
@@ -325,14 +277,15 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeUserMessage)
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
@@ -343,14 +296,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
@@ -361,14 +315,22 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeUserMessage)
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeUserMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/images/create",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeUserMessage,
},
},
})
}
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
@@ -379,14 +341,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
// --- shortcut command ---
@@ -409,43 +372,16 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
})
// shortcut: typed errs.APIError via the CallAPITyped → BuildAPIError path.
if code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", code, output.ExitAPI)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var raw struct {
OK bool `json:"ok"`
Identity string `json:"identity"`
Error struct {
Type string `json:"type"`
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
t.Fatalf("failed to parse typed envelope: %v\nstderr: %s", err, stderr.String())
}
if raw.OK {
t.Errorf("envelope ok = true, want false")
}
if raw.Identity != "bot" {
t.Errorf("identity = %q, want bot", raw.Identity)
}
if raw.Error.Type != "api" {
t.Errorf("error.type = %q, want api", raw.Error.Type)
}
if raw.Error.Code != 230002 {
t.Errorf("error.code = %d, want 230002", raw.Error.Code)
}
if raw.Error.Message != "Bot/User can NOT be out of the chat." {
t.Errorf("error.message = %q, want %q", raw.Error.Message, "Bot/User can NOT be out of the chat.")
}
// shortcut: typed error via DoAPIJSON path
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api",
Code: 230002,
Message: "Bot/User can NOT be out of the chat.",
},
})
}
// TestSetupNotices_ColdStart_NoNotice verifies that missing state

View File

@@ -137,6 +137,9 @@ func TestIsCompletionCommand(t *testing.T) {
}
}
// TestPromoteConfigError_* lives with the implementation in
// internal/errcompat/promote_test.go.
// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that
// *errs.SecurityPolicyError flows through the canonical typed envelope
// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype,
@@ -266,11 +269,12 @@ func (f *failingWriter) Write(p []byte) (int, error) {
return len(p), nil
}
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins that a
// backward-compat alias failing on a cobra-level required flag (which
// short-circuits before RunE) routes through the structured envelope, so the
// deprecation notice OnInvoke records in PreRunE is carried on the wire instead
// of being dropped on a plain "Error:" line.
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
// backward-compat alias that fails on a cobra-level required flag (which
// short-circuits before RunE) still routes through the structured envelope,
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
// switches to WriteErrorEnvelope when a deprecation is pending — so the
// migration notice is no longer dropped on the plain "Error:" line.
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
@@ -282,9 +286,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
// errs.* error, so it reaches the deprecation fallback.
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
// nor an *output.ExitError, so it reaches the legacy fallback.
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -293,96 +297,12 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
}
// The envelope is typed validation, so the exit code must derive from that
// category (2) — the wire type and the exit code must not disagree.
if exit != int(output.ExitValidation) {
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
}
}
// TestHandleRootError_AuthConfigWireGolden is the wire-consistency regression
// baseline for auth/config errors: it pins the typed envelope and exit code the
// dispatcher produces for the two source-of-truth shapes, which are constructed
// typed at their origin in internal/auth and internal/core.
func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Run("token missing exits 3 with token_missing authentication envelope", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "authentication" {
t.Errorf("error.type = %v, want %q", got, "authentication")
}
if got := errObj["subtype"]; got != "token_missing" {
t.Errorf("error.subtype = %v, want %q", got, "token_missing")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "need_user_authorization") {
t.Errorf("error.message = %q, must keep the need_user_authorization marker", got)
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "u_golden") {
t.Errorf("error.message = %q, must carry the user open id", got)
}
if got, _ := errObj["hint"].(string); !strings.Contains(got, "auth login") {
t.Errorf("error.hint = %q, must point at auth login", got)
}
if got := errObj["user_open_id"]; got != "u_golden" {
t.Errorf("error.user_open_id = %v, want %q", got, "u_golden")
}
})
t.Run("not configured exits 3 with not_configured config envelope", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, core.NotConfiguredError())
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "config" {
t.Errorf("error.type = %v, want %q", got, "config")
}
if got := errObj["subtype"]; got != "not_configured" {
t.Errorf("error.subtype = %v, want %q", got, "not_configured")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "not configured") {
t.Errorf("error.message = %q, want the not-configured message", got)
}
if got, _ := errObj["hint"].(string); !strings.Contains(got, "config init") {
t.Errorf("error.hint = %q, must point at config init", got)
}
})
}
// decodeErrorEnvelope unmarshals a typed error envelope and returns its
// top-level "error" object, failing the test if the shape is unexpected.
func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
t.Helper()
var env map[string]any
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("envelope is not valid JSON: %v\n%s", err, raw)
}
errObj, ok := env["error"].(map[string]any)
if !ok {
t.Fatalf("envelope missing top-level error object: %s", raw)
}
return errObj
}
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
// usage error (missing required flag) is typed as invalid_argument with exit 2
// even with no deprecation pending — never cobra's plain "Error:" line.
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
// fix does not reshape every unrecognized cobra error.
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
@@ -391,45 +311,9 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
t.Fatalf("want a structured envelope, got a plain Error: line:\n%s", out)
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "validation" {
t.Errorf("error.type = %v, want %q", got, "validation")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "values") {
t.Errorf("error.message = %q, must carry the failing flag name", got)
}
if exit != int(output.ExitValidation) {
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
}
}
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
// typed boundary from a helper) is classified as an internal fault (exit 5),
// not blamed on the user's input as a validation error.
func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF))
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "internal" {
t.Errorf("error.type = %v, want %q (leaked untyped error must not be mislabeled validation)", got, "internal")
}
if exit != int(output.ExitInternal) {
t.Errorf("exit = %d, want %d (internal envelope → category-derived exit)", exit, int(output.ExitInternal))
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
if !strings.HasPrefix(errOut.String(), "Error:") {
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
}
}
@@ -453,32 +337,12 @@ func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
}
}
// TestHandleRootError_BareErrorExitCodeNoStderr pins the silent-exit
// contract: a *output.BareError is honored for its exit code while stderr stays
// empty (stdout already carries the result, so the dispatcher must not layer a
// second envelope on top).
func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, output.ErrBare(output.ExitAuth))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth))
}
if errOut.Len() != 0 {
t.Errorf("stderr must stay empty for a bare predicate signal, got:\n%s", errOut.String())
}
}
// TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved pins that a typed
// *errs.AuthenticationError carrying a legacy *NeedAuthorizationError in its
// Cause chain renders the producer's TokenExpired subtype + custom hint
// verbatim — the legacy sentinel in the Cause chain never coarsens the wire
// shape.
func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
// TestHandleRootError_TypedOuterShortCircuitsPromote pins that when a typed
// *errs.AuthenticationError carries a legacy *NeedAuthorizationError in its
// Cause chain, the dispatcher does NOT run PromoteAuthError — doing so
// would replace the producer's TokenExpired subtype + custom hint with the
// promoted shape's TokenMissing.
func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -630,3 +494,136 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
}
}
// TestEnrichPermissionError_CanonicalConvergence pins that the legacy
// *output.ExitError dispatch path produces the same canonical Message + Hint
// + ConsoleURL as the typed *errs.PermissionError dispatch path. Both paths
// share errclass.CanonicalPermissionMessage / errclass.PermissionHint /
// errclass.ConsoleURL — so a wire consumer cannot tell which path produced
// the envelope.
func TestEnrichPermissionError_CanonicalConvergence(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
larkCode int
legacyErrType string
wantMsgSubstrs []string
wantHintSubstrs []string
wantConsoleURL bool
wantNoAuthLogin bool // hint must not suggest `auth login`
}{
{
name: "99991672 app_scope_not_applied",
larkCode: 99991672,
legacyErrType: "permission",
wantMsgSubstrs: []string{"access denied", "app cli_test", "drive:drive:read"},
wantHintSubstrs: []string{"developer console", "open.feishu.cn"},
wantConsoleURL: true,
wantNoAuthLogin: true,
},
{
name: "99991679 missing_scope",
larkCode: 99991679,
legacyErrType: "permission",
wantMsgSubstrs: []string{"unauthorized", "user authorization"},
wantHintSubstrs: []string{"lark-cli auth login"},
},
{
name: "99991673 app_unavailable",
larkCode: 99991673,
legacyErrType: "app_status",
wantMsgSubstrs: []string{"unauthorized app", "app cli_test", "not properly installed"},
wantHintSubstrs: []string{"tenant admin", "install status"},
},
{
name: "99991662 app_disabled",
larkCode: 99991662,
legacyErrType: "app_status",
wantMsgSubstrs: []string{"app cli_test", "not in use", "currently disabled"},
wantHintSubstrs: []string{"tenant admin", "re-enable"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
// Mimic the wire shape ErrAPI produces: legacy *ExitError with
// Detail.Type populated by ClassifyLarkError, Detail.Detail
// carrying the permission_violations block so ExtractRequiredScopes
// can recover the missing scope.
scopeForDetail := "drive:drive:read"
exitErr := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: tc.legacyErrType,
Code: tc.larkCode,
Message: "upstream raw message — must be replaced",
Detail: map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": scopeForDetail},
},
},
},
}
enrichPermissionError(f, exitErr)
for _, sub := range tc.wantMsgSubstrs {
if !strings.Contains(exitErr.Detail.Message, sub) {
t.Errorf("Message %q missing substring %q", exitErr.Detail.Message, sub)
}
}
if exitErr.Detail.Message == "upstream raw message — must be replaced" {
t.Errorf("Message must be rewritten to canonical text; got upstream verbatim")
}
for _, sub := range tc.wantHintSubstrs {
if !strings.Contains(exitErr.Detail.Hint, sub) {
t.Errorf("Hint %q missing substring %q", exitErr.Detail.Hint, sub)
}
}
if tc.wantNoAuthLogin && strings.Contains(exitErr.Detail.Hint, "auth login") {
t.Errorf("Hint must not suggest `auth login` for this subtype; got %q", exitErr.Detail.Hint)
}
if tc.wantConsoleURL && exitErr.Detail.ConsoleURL == "" {
t.Error("ConsoleURL should be populated when missing scopes are present")
}
})
}
}
// TestEnrichPermissionError_SkipsUnrelatedTypes pins that an ExitError whose
// Detail.Type is neither "permission" nor "app_status" is left untouched —
// no Message rewrite, no Hint rewrite, no ConsoleURL injection.
func TestEnrichPermissionError_SkipsUnrelatedTypes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
for _, ty := range []string{"api_error", "validation", "rate_limit", "auth"} {
exitErr := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: ty,
Code: 99991400,
Message: "untouched",
Hint: "original hint",
},
}
enrichPermissionError(f, exitErr)
if exitErr.Detail.Message != "untouched" {
t.Errorf("type=%q: Message was rewritten unexpectedly: %q", ty, exitErr.Detail.Message)
}
if exitErr.Detail.Hint != "original hint" {
t.Errorf("type=%q: Hint was rewritten unexpectedly: %q", ty, exitErr.Detail.Hint)
}
if exitErr.Detail.ConsoleURL != "" {
t.Errorf("type=%q: ConsoleURL should not be injected; got %q", ty, exitErr.Detail.ConsoleURL)
}
}
}

View File

@@ -5,11 +5,9 @@ package schema
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
@@ -211,45 +209,6 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
if !strings.Contains(err.Error(), "Unknown service") {
t.Errorf("expected 'Unknown service' error, got: %v", err)
}
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 = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available services, got: %q", ve.Hint)
}
}
// TestSchemaCmd_UnknownMethod_TypedValidation pins the typed envelope for the
// JSON-mode unknown-method path: *errs.ValidationError with
// subtype invalid_argument and a hint listing the available methods.
func TestSchemaCmd_UnknownMethod_TypedValidation(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"calendar.events.nonexistent_method"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for unknown method")
}
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 = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(err.Error(), "Unknown method") {
t.Errorf("expected 'Unknown method' error, got: %v", err)
}
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available methods, got: %q", ve.Hint)
}
}
// Completion candidate generation (dotted + space forms, strict-mode filtering,

View File

@@ -13,7 +13,6 @@ import (
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
@@ -33,16 +32,13 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
}
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
RegisterServiceCommandsFromCatalog(ctx, parent, f, registry.RuntimeCatalog())
}
func RegisterServiceCommandsFromCatalog(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory, catalog apicatalog.Catalog) {
// Drive the service list from the same navigation catalog the method walk
// uses, so registration is catalog-sourced end to end. Kept as a per-service
// loop rather than a flat WalkMethods(nil) drive precisely so a service with
// no methods still gets its bare command (WalkMethods yields one ref per
// method, so empty services would vanish).
for _, svc := range catalog.Services() {
// uses — RuntimeCatalog().Services() is the deterministic, sorted view of the
// merged metadata — so registration is catalog-sourced end to end. Kept as a
// per-service loop rather than a flat WalkMethods(nil) drive precisely so a
// service with no methods still gets its bare command (WalkMethods yields one
// ref per method, so empty services would vanish).
for _, svc := range registry.RuntimeCatalog().Services() {
if svc.Name == "" || svc.ServicePath == "" {
continue
}
@@ -88,12 +84,10 @@ func serviceShort(svc meta.Service) string {
func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command {
for _, c := range parent.Commands() {
if c.Name() == name {
cmdmeta.SetSource(c, cmdmeta.SourceService, true)
return c
}
}
cmd := &cobra.Command{Use: name, Short: short}
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
parent.AddCommand(cmd)
return cmd
}
@@ -237,7 +231,6 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
return serviceMethodRun(opts)
},
}
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.")
if spec.acceptsBody {
@@ -387,7 +380,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
checkErr := ac.CheckResponse
if opts.PageAll {
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
}
@@ -627,45 +620,20 @@ func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *cor
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
}
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 err
@@ -675,12 +643,7 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
}
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:
@@ -689,14 +652,9 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return 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
}
}

View File

@@ -4,15 +4,10 @@
package service
import (
"context"
"encoding/json"
"errors"
"os"
"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"
@@ -412,19 +407,8 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
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())
}
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.Errorf("expected 'success' in output, got:\n%s", stdout.String())
}
}
@@ -452,312 +436,8 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
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 serviceContentSafetyProvider struct {
called bool
path string
data interface{}
match string
}
func (p *serviceContentSafetyProvider) Name() string { return "service-test" }
func (p *serviceContentSafetyProvider) 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: "service-test", MatchedRules: []string{"pagination"}}, nil
}
func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-safety", AppSecret: "test-secret-service-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--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 != "list" {
t.Fatalf("scan path = %q, want list", 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"] != "service-test" {
t.Fatalf("missing content safety alert in envelope: %#v", got)
}
}
func TestServiceMethod_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-safety", AppSecret: "test-secret-service-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--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 != "list" {
t.Fatalf("scan path = %q, want list", 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 service-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 TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
provider := &serviceContentSafetyProvider{match: "blocked"}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-block", AppSecret: "test-secret-service-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
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/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--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 TestServiceMethod_BusinessErrorReturnsTypedErrorWithoutSuccessEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-err", AppSecret: "test-secret-service-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
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)
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_DefaultBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-err", AppSecret: "test-secret-service-pageall-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
t.Fatalf("expected raw error response on 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())
}
}
func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-stream-err", AppSecret: "test-secret-service-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
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/svc/v1/items",
Body: map[string]interface{}{
"code": 230027,
"msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
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)
if !strings.Contains(stdout.String(), `"id"`) {
t.Errorf("expected items in output, got:\n%s", stdout.String())
}
}
@@ -949,51 +629,6 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
}
}
func TestServiceMethod_PageAll_WithJqBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-spjq-err", AppSecret: "test-secret-spjq-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
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)
}
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
t.Fatalf("expected raw error response on 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())
}
}
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)
}
}
// ── file upload ──
func imImageMethod() meta.Method {

View File

@@ -11,7 +11,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -127,20 +126,29 @@ func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
}
// Typed surface: a validation error (exit 2) whose Params carries the
// offending flag so an agent can recover the token without parsing prose.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--badflag" {
t.Errorf("params = %v, want one entry named --badflag", verr.Params)
if detail["unknown"] != "--badflag" {
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
}
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
}
for _, key := range []string{"suggestions", "valid_flags"} {
if _, present := detail[key]; !present {
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
}
}
}
@@ -164,21 +172,25 @@ func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing
if err == nil {
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if !strings.Contains(verr.Message, "missing subcommand") {
t.Errorf("message = %q, want it to mention a missing subcommand", verr.Message)
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--query" {
t.Errorf("params = %v, want one entry named --query", verr.Params)
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
}
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message = %q, want it to name the group path", verr.Message)
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
}
}
@@ -229,23 +241,45 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
t.Fatal("expected error for unknown subcommand")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
if exitErr.Code != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code)
}
if !strings.Contains(verr.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", verr.Message)
if exitErr.Detail == nil {
t.Fatal("expected ExitError to carry Detail")
}
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message should name the group path, got %q", verr.Message)
if exitErr.Detail.Type != "unknown_subcommand" {
t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
}
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
// back to pointing at --help (suggestions, when present, are folded into hint).
if !strings.Contains(verr.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", verr.Hint)
// back to pointing at --help; the full machine-readable list lives in
// detail.available below (which also excludes hidden commands).
if !strings.Contains(exitErr.Detail.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected Detail.Detail to be map[string]any, got %T", exitErr.Detail.Detail)
}
if detail["unknown"] != "+bogus" {
t.Errorf("detail.unknown should be +bogus, got %v", detail["unknown"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path should be %q, got %v", "lark-cli drive", detail["command_path"])
}
available, ok := detail["available"].([]string)
if !ok {
t.Fatalf("detail.available should be []string, got %T", detail["available"])
}
if len(available) != 3 {
t.Errorf("expected 3 available entries (hidden excluded), got %d: %v", len(available), available)
}
}
@@ -254,12 +288,13 @@ func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) {
installUnknownSubcommandGuard(root)
err := files.RunE(files, []string{"bogus"})
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError on nested group, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError on nested group, got %T", err)
}
if !strings.Contains(verr.Message, "lark-cli drive files") {
t.Errorf("message should reflect the nested resource path, got %q", verr.Message)
if exitErr.Detail.Detail.(map[string]any)["command_path"] != "lark-cli drive files" {
t.Errorf("command_path should reflect the nested resource, got %v",
exitErr.Detail.Detail.(map[string]any)["command_path"])
}
}
@@ -302,10 +337,10 @@ func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
}
}
// unknownSubcommandRunE ranks suggestions across both current and deprecated
// subcommands so a mistyped legacy alias resolves; the closest match is folded
// into the hint.
func TestUnknownSubcommandRunE_SuggestsAcrossDeprecatedBucket(t *testing.T) {
// unknownSubcommandRunE must split current vs deprecated subcommands into
// separate detail buckets, while suggestions still rank across both so a
// mistyped legacy alias resolves.
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
svc.AddCommand(
@@ -314,26 +349,31 @@ func TestUnknownSubcommandRunE_SuggestsAcrossDeprecatedBucket(t *testing.T) {
)
err := unknownSubcommandRunE(svc, []string{"+reat"})
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
// "+reat" is closest to the deprecated +read: the candidate must surface
// both as a machine-readable param suggestion (for agent retry) and in the
// hint, proving ranking spans the deprecated bucket.
if len(verr.Params) != 1 || verr.Params[0].Name != "+reat" {
t.Fatalf("params = %v, want one entry named +reat (the offending subcommand)", verr.Params)
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
}
foundSuggestion := false
for _, s := range verr.Params[0].Suggestions {
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
t.Errorf("available = %v, want [+cells-get]", available)
}
deprecated, ok := detail["deprecated"].([]string)
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
t.Errorf("deprecated = %v, want [+read]", deprecated)
}
// suggestions rank across both buckets: "+reat" is closest to +read.
suggestions, _ := detail["suggestions"].([]string)
found := false
for _, s := range suggestions {
if s == "+read" {
foundSuggestion = true
found = true
}
}
if !foundSuggestion {
t.Errorf("Params[0].Suggestions should include +read, got %v", verr.Params[0].Suggestions)
}
if !strings.Contains(verr.Hint, "+read") {
t.Errorf("hint %q should suggest +read (typo target across deprecated bucket)", verr.Hint)
if !found {
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
}
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
@@ -133,14 +132,12 @@ func updateRun(opts *UpdateOptions) error {
// 1. Fetch latest version
latest, err := fetchLatest()
if err != nil {
return reportError(opts, io, "network",
errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: %s", err).WithCause(err))
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
}
// 2. Validate version format
if update.ParseVersion(latest) == nil {
return reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid version from registry: %s", latest))
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
}
// 3. Compare versions
@@ -169,18 +166,15 @@ func updateRun(opts *UpdateOptions) error {
// --- Output helpers ---
// reportError emits the failure on the requested surface: JSON mode prints the
// {ok:false, error:{type, message}} envelope to stdout and signals the typed
// error's exit code bare; human mode returns the typed error for the
// dispatcher to render.
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, errType string, typedErr errs.TypedError) error {
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message},
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
})
return output.ErrBare(output.ExitCodeOf(typedErr))
return output.ErrBare(exitCode)
}
return typedErr
return output.Errorf(exitCode, errType, "%s", msg)
}
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
@@ -234,8 +228,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, "update_error",
errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: %s", err).WithCause(err))
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
}
if !opts.JSON {

View File

@@ -14,7 +14,6 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -335,88 +334,13 @@ func TestUpdateFetchError_Human(t *testing.T) {
if err == nil {
t.Fatal("expected non-nil error, got nil")
}
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError, got %T: %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if netErr.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %q, want %q", netErr.Subtype, errs.SubtypeNetworkTransport)
if exitErr.Code != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code)
}
if got := output.ExitCodeOf(err); got != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, got)
}
}
// TestUpdateInvalidVersion_Human verifies a malformed registry version surfaces
// as a typed internal error in human mode, keeping the legacy exit code 5.
func TestUpdateInvalidVersion_Human(t *testing.T) {
f, _, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "not-a-version", nil }
defer func() { fetchLatest = origFetch }()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
if err == nil {
t.Fatal("expected non-nil error, got nil")
}
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("expected ExitInternal (%d), got %d", output.ExitInternal, got)
}
}
// TestReportError pins reportError's two surfaces after the typed migration:
// human mode returns the typed error unchanged; JSON mode prints the legacy
// {ok:false, error:{type, message}} envelope and exits bare with the typed
// error's exit code (parity with the legacy explicit exit-code argument).
func TestReportError(t *testing.T) {
t.Run("human mode returns the typed error", func(t *testing.T) {
f, _, _ := newTestFactory(t)
typed := errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: disk full")
err := reportError(&UpdateOptions{JSON: false}, f.IOStreams, "update_error", typed)
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *errs.APIError, got %T: %v", err, err)
}
if apiErr != typed {
t.Errorf("reportError must return the typed error unchanged")
}
if got := output.ExitCodeOf(err); got != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI, legacy parity)", got, output.ExitAPI)
}
})
t.Run("json mode prints envelope and exits bare with typed code", func(t *testing.T) {
f, stdout, _ := newTestFactory(t)
typed := errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: timeout")
err := reportError(&UpdateOptions{JSON: true}, f.IOStreams, "network", typed)
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected bare *output.BareError, got %T: %v", err, err)
}
if bareErr.Code != output.ExitNetwork {
t.Errorf("bare exit code = %d, want %d", bareErr.Code, output.ExitNetwork)
}
out := stdout.String()
if !strings.Contains(out, `"type": "network"`) && !strings.Contains(out, `"type":"network"`) {
t.Errorf("JSON envelope missing type, got: %s", out)
}
if !strings.Contains(out, "failed to check latest version: timeout") {
t.Errorf("JSON envelope missing message, got: %s", out)
}
})
}
func TestUpdateInvalidVersion_JSON(t *testing.T) {
@@ -579,12 +503,12 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
if err == nil {
t.Fatal("expected verification failure")
}
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if bareErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, bareErr.Code)
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code)
}
out := stdout.String()

View File

@@ -6,16 +6,25 @@ envelope on stderr; **protocol adapters** mapping CLI errors into MCP /
OAuth shapes; and **framework + business code** producing errors. This file
is the single source of truth for all three.
Something off in production? See **Troubleshooting**.
This document describes the **typed authoring target**. The refactor lands
in stages; some boundaries (e.g. `client.WrapDoAPIError`) still operate on
legacy shapes today — see **Migration** for what is live in each stage.
Migrating an `*output.ExitError` call site? See **Migration**. Something off
in production? See **Troubleshooting**.
## Invariants
1. Every error belongs to exactly one **Category**. The set is closed
(`errs/category.go`); adding a member requires deliberate review.
2. Every typed error has a **Subtype** — a stable
2. Every **newly constructed** typed error has a **Subtype** — a stable
lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
Undeclared subtypes fail CI. Every error path constructs a typed
`*errs.*` error at its origin, so the constraint applies uniformly.
Undeclared subtypes fail CI. The constraint applies only to typed
`*errs.*` literals; stage-1 legacy `*core.ConfigError` flows via the
dispatcher's `asExitError` → legacy envelope path (not the typed
taxonomy) and is unaffected. `errcompat.PromoteConfigError` is a
stage-1 passthrough; its stage-2+ typed migration will subject the
promoted typed error to this Subtype constraint at that time.
3. **`Category` + `Subtype`** are wire-stable identifiers consumers may
branch on. Renaming either is a breaking change.
4. `Code` is the upstream numeric code when known (e.g. Lark API code).
@@ -26,10 +35,11 @@ Something off in production? See **Troubleshooting**.
unchanged across the `errors.As` / `errors.Unwrap` chain.
7. For the typed-envelope path, exit codes derive from `Category` only
via `output.ExitCodeForCategory` — including `SecurityPolicyError`,
which exits `6` via `CategoryPolicy`. `output.ErrBare(code)` is the
exception: it constructs an `*output.BareError`, a deliberate
silent-exit signal (stdout already carries the answer) that bypasses
the envelope (see **Predicate commands** below).
which exits `6` via `CategoryPolicy`. Unmigrated `*output.ExitError`
producers still carry a hand-set `Code` until they finish migrating.
`output.ErrBare(code)` is the lone exception: a deliberate
predicate-command signal that bypasses the envelope (see
**Predicate commands** below).
## Wire format
@@ -63,14 +73,13 @@ Typed errors render to **stderr** as one JSON object per process exit:
| `error.hint` | informational | actionable recovery guidance |
| `error.log_id` | informational | upstream request id (server-side trace) |
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
| `error.param` | per-Subtype-stable | single offending parameter (`ValidationError`); see **Validation parameters** |
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
`SecurityPolicyError` renders through the same typed envelope as every
other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`.
`CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been
retired.
## Categories
@@ -110,21 +119,20 @@ Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
cmd/root.go handleRootError dispatches:
├─ output.ErrBare(code) → no envelope (stdout already written); exit = code
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6;
│ *errs.ConfigError, constructed typed at origin)
├─ *output.PartialFailureError → no stderr envelope (ok:false result already on stdout); exit = code
*output.BareError → no envelope (stdout already written); exit = code
└─ Cobra usage error → typed validation envelope (invalid_argument); exit 2
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6)
├─ *core.ConfigError → promoted to typed via errcompat ↑
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
```
The dispatcher emits a JSON envelope on stderr for both the typed branch and
residual Cobra usage errors (missing required flag, unknown command,
argument validation): the latter are classified into a typed validation
envelope (`invalid_argument`) and exit `2`, matching the explicit flag and
subcommand guards.
Only the typed and `*output.ExitError` branches emit a JSON envelope on
stderr. Untyped errors (including Cobra's "required flag missing" / unknown
subcommand messages) print plain text and exit `1` — consumers must
tolerate that fallback.
### Predicate commands (`output.BareError`)
### Predicate commands (`output.ErrBare`)
A small class of commands is **predicates**: they answer a yes/no
question and signal the answer through the shell exit code so callers
@@ -134,27 +142,19 @@ example — its `README` contract is `exit 0 = ok, 1 = missing`.
These commands deliberately:
1. write a structured JSON answer to **stdout** themselves, and
2. return `output.ErrBare(exitCode)` — an `*output.BareError` to
communicate the exit code to the dispatcher without producing a
`stderr` envelope.
2. return `output.ErrBare(exitCode)` to communicate the exit code to
the dispatcher without producing a `stderr` envelope.
`*output.BareError` is **not** an error in the typed-envelope sense — it
carries no category, subtype, or message, only an exit code. It is a
one-bit output-control signal that lives outside the contract for the
same reason `grep -q` / `diff` / `systemctl is-active` set non-zero exit
codes without printing anything to stderr: pollution of stderr by a
`output.ErrBare` is **not** an error in the typed-envelope sense — it
carries no category, subtype, or message. It is a one-bit output-
control signal that lives outside the contract for the same reason
`grep -q` / `diff` / `systemctl is-active` set non-zero exit codes
without printing anything to stderr: pollution of stderr by a
predicate's negative answer would break `2>/dev/null` log hygiene in
caller scripts.
A second class also uses `ErrBare`: a command that emits its own complete
structured result envelope on **stdout** under `--json` (e.g. `update`, whose
`{ok:false, error:{type, message}}` is its established output shape) and needs
only the exit code conveyed, with no `stderr` envelope. Like a predicate, its
answer is already on stdout; `ErrBare` carries the exit code alone.
New code should not reach for `ErrBare` unless the command's full answer is
already on stdout — a predicate's yes/no, or a self-contained result envelope
as above. Anything whose error content must reach the caller on `stderr`
New code should not reach for `ErrBare` unless the command is
genuinely a predicate. Anything carrying recoverable error content
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
partial-failure outcome below.
@@ -214,7 +214,7 @@ exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors
out=$(lark-cli ... 2>&1)
code=$?
# Defensive guard: tolerate any non-JSON output before parsing with jq.
# Untyped / Cobra errors print plain text — guard before jq.
if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
printf '%s\n' "$out" >&2
exit "$code"
@@ -303,10 +303,9 @@ Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory`
maps `Category` to the shell code. A new exit-code requirement means a
new `Category`, not a one-off override at the call site.
(The only exits not derived from `Category` are the
`*output.BareError` and the `*output.PartialFailureError` signals, which
carry their own code by design and sit outside the typed-envelope contract —
see **Predicate commands**.)
(Legacy `*output.ExitError` retains hand-set codes until removal;
`SecurityPolicyError` retains a hand-set code on main until the framework
migration PR retires the carve-out — see **Migration**.)
#### Split `Message`, `Hint`, and `Cause`
@@ -341,54 +340,15 @@ Message: fmt.Sprintf("request failed: %v — retry later", ioErr)
// conflates what + what-to-do + cause into one string
```
#### Validation parameters: `Param` and `Params`
#### `ValidationError.Param` uses the `--flag` form
`ValidationError` carries two additive parameter fields. Both are
optional; a producer sets whichever fits the failure.
When a `*ValidationError` originates from a flag value, `Param` holds the
flag name with leading dashes (`"--priority"`, not `"priority"`). AI
agents grep this field literally to surface "the bad flag was `--X`".
**`Param string` (wire `param`)** — the single offending parameter. When a
`*ValidationError` originates from a flag value, `Param` holds the flag
name with leading dashes (`"--priority"`, not `"priority"`). AI agents
grep this field literally to surface "the bad flag was `--X`". For
positional arguments, use the canonical name without dashes
For positional arguments, use the canonical name without dashes
(`"target_user_id"`).
**`Params []InvalidParam` (wire `params`)** — per-parameter validation
detail, for failures that need to report *which* parameters failed and
*why*, one entry each. Each `errs.InvalidParam` is
`{Name, Reason string, Suggestions []string}`: `Name` identifies the
parameter, `Reason` states why it failed, and the optional `Suggestions`
(wire `suggestions`, omitted when empty) carries ranked candidate
corrections an agent can retry with — the did-you-mean candidates for an
unknown flag or subcommand — without parsing the human-facing `hint`. This
is the CLI's rendering of the RFC 7807 `invalid-params` extension member
(RFC 7807 §3.1). The wire key is `params`, not `invalid_params`: the
enclosing envelope already carries `type:"validation"`, so the `invalid_`
qualifier would be redundant on the wire.
`Param` and `Params` are independent additive fields, not alternates of a
single representation. Use `Param` for the common single-parameter error;
use `Params` when one failure spans several parameters or needs a
per-parameter reason. Set with `.WithParam("--flag")` / `.WithParams(...)`.
A `params` wire example (multiple parameters each carrying a reason):
```json
{
"ok": false,
"identity": "user",
"error": {
"type": "validation",
"subtype": "invalid_argument",
"message": "2 parameters failed validation",
"params": [
{ "name": "--start", "reason": "expected RFC3339, got \"yesterday\"" },
{ "name": "--end", "reason": "must be after --start" }
]
}
}
```
### Constructing typed errors
Prefer the **builder API**. The constructor pins `Category` + `Subtype` +
@@ -418,11 +378,44 @@ them on the dynamic dispatch path where a `Problem` value is composed
once and wrapped per Category branch. Outside that pattern, new code
should reach for the builder.
When the validation logic outgrows a single range check — multiple flags,
format parsing, conditional rules — extract it into a helper that also returns
the typed `*errs.ValidationError`; the helper, not `Execute`, sets `Param` (a
helper bound to one shortcut is normal in this codebase; see `parseTimeRange`
in `shortcuts/calendar/calendar_agenda.go`).
Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
remain callable during migration but are `// Deprecated:` — new code goes
through the builder.
#### Shortcut `Execute` walkthrough
Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy
form is `output.ErrValidation("--duration-minutes must be between 1 and
1440")`. The typed migration target (builder form):
```go
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
duration := runtime.Int("duration-minutes")
if duration < 1 || duration > 1440 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--duration-minutes must be between 1 and 1440, got %d", duration).
WithHint("pass a value in [1, 1440]").
WithParam("--duration-minutes")
}
_, err := runtime.DoAPI(req, opts)
if err != nil {
return err // already typed by the framework boundary; propagate
}
return nil
}
```
Two patterns visible: a producer site (the typed `*errs.ValidationError`
above) and a propagation site (the `return err` after `runtime.DoAPI`,
applying [Propagate typed errors unchanged](#propagate-typed-errors-unchanged)).
When the validation logic outgrows a single range check — multiple
flags, format parsing, conditional rules — extract it into a helper that
also returns the typed `*errs.ValidationError`. The helper, not
`Execute`, sets `Param` (a helper bound to one shortcut is normal in
this codebase; see `parseTimeRange` in
`shortcuts/calendar/calendar_agenda.go:144`).
### Wrapping upstream errors
@@ -486,7 +479,7 @@ Rare; the existing structs cover the 9 Categories with room. If you must:
1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field.
2. Add an `IsXxx` predicate in `errs/predicates.go`.
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_test.go`.
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_builder_test.go`.
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the
@@ -495,33 +488,19 @@ top level.
## CI guards
Two golangci-lint rules and the custom `errscontract` AST module enforce the
contract; CI runs all three on every PR.
| Check | Enforces | Where |
|-------|----------|-------|
| forbidigo | business path (`shortcuts/**`, `cmd/service/**`) must not call legacy `output.*` error constructors — route through the typed classifier | `.golangci.yml` |
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | `lint/errscontract/` AST |
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `lint/errscontract/` AST |
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes labeled for promotion (warn) | `lint/errscontract/` AST |
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant or `ad_hoc_*` | `lint/errscontract/` AST |
| `CheckTypedErrorCompleteness` | every `*errs.<X>Error{Problem: errs.Problem{...}}` literal must set `Category`, `Subtype`, and `Message` | `lint/errscontract/` AST |
**golangci-lint** — scopes are defined in `.golangci.yml` (not duplicated here,
so this spec cannot drift from the lint config):
| Rule | Enforces |
|------|----------|
| forbidigo `errs-no-bare-wrap` | a command / wire-boundary final error must be typed (`errs.NewXxxError`), never a bare `fmt.Errorf` / `errors.New`; a genuine intermediate wrap opts out with `//nolint:forbidigo` + a reason |
| errorlint | every error wrap uses `%w` and every comparison uses `errors.Is` / `errors.As` — interior wraps stay legal but cannot break the `errors.Unwrap` chain the typed boundary relies on |
**errscontract** (`lint/errscontract/`, a separate Go module so its
`golang.org/x/tools` dependency stays out of the shipped binary; run locally
with `go run -C lint . ..`):
| Check | Enforces |
|-------|----------|
| `CheckNoLegacyEnvelopeLiteral` / `CheckNoLegacyCommonHelperCall` / `CheckNoLegacyRuntimeAPICall` | the removed `output.*` legacy error surface cannot be reintroduced anywhere |
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` |
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant (or `ad_hoc_*`) |
| `CheckTypedErrorCompleteness` | every typed-error struct literal sets `Category`, `Subtype`, and `Message` |
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes flagged for promotion (warning) |
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code |
`errscontract` also carries framework-internal invariants (nil-safe `Unwrap`,
builder immutability, unwrap symmetry); see `lint/errscontract/` for the full
set and `lint/README.md` for adding a new lint domain.
CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The
lintcheck CLI lives in its own Go module so its `golang.org/x/tools`
dependency stays out of the shipped `lark-cli` binary's module graph;
see `lint/README.md` for how to add a new lint domain.
## Stability
@@ -531,13 +510,67 @@ set and `lint/README.md` for adding a new lint domain.
| Additive | new Category, new declared Subtype, new extension field on an existing struct | minor release; consumers ignore unknown fields by contract |
| Experimental | `ad_hoc_*` Subtypes; fields documented as such in `errs/types.go` | may change or be promoted/removed within one release |
The deprecated `*output.ExitError` surface is outside these tiers — it
will be removed once business migration completes.
## Migration
**Strategy shift (2026-05-26).** The original plan (`docs/design/errors-refactor/spec.md` v2.12 §9) was a centrally-driven 4-PR rollout — framework → auth domain → multi-pilot → full-repo + legacy removal. That plan is **superseded** by a hybrid model: framework owner ships framework-level hardening (including a typed `*errs.*Error` migration of `internal/**`) as one focused PR; business-domain typed migration is **self-service** via [`docs/errors-guide.md`](../docs/errors-guide.md) and the builder API, with no central sweep timeline.
Why the shift: 800+ legacy call sites split across 8+ business domains do not all share a single reviewer's bandwidth, and the contract is now expressive enough that each domain owner can migrate their own code from the guide without coordinating with framework owner.
### Current state
1. **Framework slice — ✅ shipped (PR #984).** The `errs/` typed taxonomy, classifier (`internal/errclass`), promotion stub (`internal/errcompat`, passthrough), dispatcher hook (`WriteTypedErrorEnvelope`), and the `lint/errscontract` AST guards. Wire shapes preserved byte-for-byte versus pre-PR, with **one intentional semantic fix**: config-class errors (`*core.ConfigError`) now exit `3` instead of `2`, aligning with `ExitCodeForCategory` (config errors share the auth exit slot per the taxonomy). The classifier and promote helpers are *shipped but unused* in production paths — they exist so framework migration can plug in without re-architecting.
2. **Builder API — ✅ shipped (this branch).** `errs/types.go` adds the canonical producer surface (`errs.NewXxxError(subtype, format, args...).WithX(...)`) for all 10 typed types, alongside each struct declaration. Constructor signature pins `Category` (via function name) and `Subtype` + `Message` (positional), so the producer cannot mis-specify any of the three identity fields. Optional fields chain through `.WithX(...)` setters that preserve the concrete pointer type.
### Next: framework migration PR (planned)
A single PR consolidates the work the original §9 spec split across PRs 24 — restricted to framework code, no business sweep:
- **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`.
- **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`.
- **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder).
- **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code).
### Business-domain migration (self-service, no central timeline)
Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover.
Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally.
### Legacy removal
Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date.
### Before / after at a call site
```go
// before (legacy)
return output.ErrAPI(larkCode, "create event failed", resp.RawBody())
// after (typed) — cc carries Brand / AppID / Identity from the caller's context
return errclass.BuildAPIError(parsedResp, cc)
```
```go
// before (legacy validation)
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
// after (builder)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--duration-minutes must be between 1 and 1440, got %d", duration).
WithParam("--duration-minutes")
```
## Troubleshooting
**Envelope shows `type=api subtype=unknown` for what should be a more
specific category.** The Lark code is unknown to `LookupCodeMeta` and fell
through to the generic bucket (`internal/errclass/classify.go`). Add the
code to `internal/errclass/codemeta_<service>.go` with the right Category
and Subtype, plus a dispatch test in `internal/errclass/classify_test.go`.
and Subtype, plus a dispatch test in `classify_test.go`.
**Envelope shows `type=internal subtype=sdk_error`.** Origin is
`client.WrapDoAPIError` taking the non-transport branch
@@ -580,6 +613,8 @@ string cannot be classified retroactively.
- *Add a new condition?* → **Add a Subtype**
- *Consume from a shell script?* → **Consumers / Shell / AI**
- *Understand or fix a CI failure?* → **CI guards**
- *Migrate a legacy `ExitError` call site?* → **Migration** + the
Deprecated note on the symbol being replaced.
- *Read source.* → `errs/doc.go``errs/category.go``errs/types.go`
`errs/predicates.go``internal/errclass/`
`cmd/root.go` `handleRootError`.

View File

@@ -1,29 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import "errors"
// rawPassthrough marks an error as raw passthrough: the dispatcher must not
// rewrite its message or hint with local enrichment. Raw is
// dispatcher-internal routing state, not a wire field. It is deliberately not
// a typed taxonomy error (no embedded Problem) — it only wraps one.
type rawPassthrough struct{ err error }
func (e *rawPassthrough) Error() string { return e.err.Error() }
func (e *rawPassthrough) Unwrap() error { return e.err }
// MarkRaw wraps err as raw passthrough. MarkRaw(nil) returns nil.
func MarkRaw(err error) error {
if err == nil {
return nil
}
return &rawPassthrough{err: err}
}
// IsRaw reports whether err or any error in its chain is marked raw.
func IsRaw(err error) bool {
var raw *rawPassthrough
return errors.As(err, &raw)
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs_test
import (
"encoding/json"
"errors"
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestMarkRawNilReturnsNil(t *testing.T) {
if got := errs.MarkRaw(nil); got != nil {
t.Fatalf("MarkRaw(nil) = %v, want nil", got)
}
}
func TestIsRaw(t *testing.T) {
base := fmt.Errorf("boom")
if !errs.IsRaw(errs.MarkRaw(base)) {
t.Errorf("IsRaw(MarkRaw(err)) = false, want true")
}
if errs.IsRaw(base) {
t.Errorf("IsRaw(bare err) = true, want false")
}
if errs.IsRaw(nil) {
t.Errorf("IsRaw(nil) = true, want false")
}
// Raw marking survives further wrapping above it in the chain.
wrapped := fmt.Errorf("outer: %w", errs.MarkRaw(base))
if !errs.IsRaw(wrapped) {
t.Errorf("IsRaw(wrap(MarkRaw(err))) = false, want true")
}
}
func TestMarkRawPreservesErrorMessage(t *testing.T) {
base := fmt.Errorf("boom")
if got := errs.MarkRaw(base).Error(); got != "boom" {
t.Fatalf("MarkRaw(err).Error() = %q, want %q", got, "boom")
}
}
func TestMarkRawPreservesErrorsIsChain(t *testing.T) {
sentinel := errors.New("sentinel")
wrapped := fmt.Errorf("ctx: %w", sentinel)
if !errors.Is(errs.MarkRaw(wrapped), sentinel) {
t.Fatalf("errors.Is(MarkRaw(err), sentinel) = false, want true")
}
}
func TestProblemOfPunchesThroughMarkRaw(t *testing.T) {
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad flag")
raw := errs.MarkRaw(typed)
p, ok := errs.ProblemOf(raw)
if !ok {
t.Fatalf("ProblemOf(MarkRaw(typed)) ok = false, want true")
}
if p.Category != errs.CategoryValidation {
t.Errorf("ProblemOf(MarkRaw(typed)).Category = %v, want %v", p.Category, errs.CategoryValidation)
}
// errors.As still finds the concrete typed error through the raw wrapper.
var ve *errs.ValidationError
if !errors.As(raw, &ve) {
t.Errorf("errors.As(MarkRaw(typed), *ValidationError) = false, want true")
}
}
// TestMarkRawUnwrapsToInnerTypedError pins the envelope-serialization
// contract: UnwrapTypedError must return the inner concrete typed error,
// not the rawPassthrough wrapper. The wrapper has no exported fields, so if it
// were returned the JSON envelope would marshal to an empty "{}" error.
func TestMarkRawUnwrapsToInnerTypedError(t *testing.T) {
base := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad flag")
typed, ok := errs.UnwrapTypedError(errs.MarkRaw(base))
if !ok {
t.Fatal("UnwrapTypedError(MarkRaw(typed)) must find a typed error")
}
out, err := json.Marshal(typed)
if err != nil {
t.Fatal(err)
}
if string(out) == "{}" {
t.Fatalf("UnwrapTypedError returned the opaque rawPassthrough wrapper; envelope would be empty: %s", out)
}
if got := errs.CategoryOf(typed); got != errs.CategoryValidation {
t.Fatalf("unwrapped category = %q, want validation", got)
}
}

View File

@@ -73,7 +73,6 @@ const (
const (
SubtypeChallengeRequired Subtype = "challenge_required" // user must complete browser challenge / MFA
SubtypeAccessDenied Subtype = "access_denied" // policy denies access outright
SubtypeContentSafety Subtype = "content_safety" // content-safety scanner blocked output in block mode
)
// CategoryInternal subtypes

View File

@@ -77,10 +77,6 @@ type ValidationError struct {
type InvalidParam struct {
Name string `json:"name"`
Reason string `json:"reason"`
// Suggestions holds machine-readable, ranked candidate corrections for this
// parameter (e.g. did-you-mean flags or subcommands), so an agent can retry
// without parsing the human-facing hint. Omitted when there are none.
Suggestions []string `json:"suggestions,omitempty"`
}
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse

View File

@@ -101,9 +101,9 @@ func TestSecurityPolicyErrorUnwrap(t *testing.T) {
// interface would panic when the root dispatcher or any caller walks the
// errors.Is / errors.Unwrap chain.
//
// The doc comments on these types claim "nil-receiver safe"; this test
// pins that claim so the behavioral comment cannot silently drift from the
// implementation.
// The doc comments on these types claim "nil-receiver safe" but until this
// test landed nothing actually pinned that claim — exactly the
// behavioral-comment-without-test footgun caught in PR #984 review.
func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
t.Helper()
checks := []struct {

View File

@@ -1,132 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
)
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
type CardActionTriggerOutput struct {
Type string `json:"type" desc:"Event type; always card.action.trigger"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"`
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"`
ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"`
Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"`
Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"`
ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"`
ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"`
ActionName string `json:"action_name,omitempty" desc:"Element name attribute"`
FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"`
InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"`
Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"`
Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"`
Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"`
Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"`
CardContent string `json:"card_content,omitempty" desc:"Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"`
}
func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Operator struct {
OpenID string `json:"open_id"`
} `json:"operator"`
Token string `json:"token"`
Host string `json:"host"`
Action struct {
Tag string `json:"tag"`
Value map[string]interface{} `json:"value"`
Name string `json:"name"`
FormValue map[string]interface{} `json:"form_value"`
InputValue string `json:"input_value"`
Option string `json:"option"`
Options []string `json:"options"`
Checked bool `json:"checked"`
Timezone string `json:"timezone"`
} `json:"action"`
Context struct {
OpenMessageID string `json:"open_message_id"`
OpenChatID string `json:"open_chat_id"`
} `json:"context"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
}
actionValue := marshalToString(envelope.Event.Action.Value)
formValue := marshalToString(envelope.Event.Action.FormValue)
options := strings.Join(envelope.Event.Action.Options, ",")
out := &CardActionTriggerOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
OperatorID: envelope.Event.Operator.OpenID,
MessageID: envelope.Event.Context.OpenMessageID,
ChatID: envelope.Event.Context.OpenChatID,
Host: envelope.Event.Host,
Token: envelope.Event.Token,
ActionTag: envelope.Event.Action.Tag,
ActionValue: actionValue,
ActionName: envelope.Event.Action.Name,
FormValue: formValue,
InputValue: envelope.Event.Action.InputValue,
Option: envelope.Event.Action.Option,
Options: options,
Checked: envelope.Event.Action.Checked,
Timezone: envelope.Event.Action.Timezone,
}
if out.MessageID != "" && rt != nil {
out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID)
}
return json.Marshal(out)
}
// fetchCardUserDSL gets the card message content via message get API.
// Returns empty string on any failure — never blocks event consumption.
func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string {
path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content"
resp, err := rt.CallAPI(ctx, "GET", path, nil)
if err != nil {
return ""
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Items []struct {
Body struct {
Content string `json:"content"`
} `json:"body"`
} `json:"items"`
} `json:"data"`
}
if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 {
return ""
}
return result.Data.Items[0].Body.Content
}
func marshalToString(m map[string]interface{}) string {
if len(m) == 0 {
return ""
}
b, _ := json.Marshal(m)
return string(b)
}

View File

@@ -1,432 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestCardActionTriggerRegistered(t *testing.T) {
def, ok := event.Lookup("card.action.trigger")
if !ok {
t.Fatal("card.action.trigger should be registered via Keys()")
}
if def.Schema.Custom == nil {
t.Error("card.action.trigger must set Schema.Custom")
}
if def.Process == nil {
t.Error("card.action.trigger must set Process")
}
if len(def.Scopes) == 0 {
t.Error("Scopes must not be empty")
}
}
func TestProcessCardAction_Button(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_btn_001",
"event_type": "card.action.trigger",
"create_time": "1776409469273"
},
"event": {
"operator": {"open_id": "ou_operator"},
"token": "c-token-btn",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "approve"},
"name": "approve_btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_msg_001",
"open_chat_id": "oc_chat_001"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Type != "card.action.trigger" {
t.Errorf("Type = %q, want card.action.trigger", out.Type)
}
if out.EventID != "ev_btn_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.OperatorID != "ou_operator" {
t.Errorf("OperatorID = %q", out.OperatorID)
}
if out.ActionTag != "button" {
t.Errorf("ActionTag = %q, want button", out.ActionTag)
}
if out.ActionValue != `{"key":"approve"}` {
t.Errorf("ActionValue = %q", out.ActionValue)
}
if out.ActionName != "approve_btn" {
t.Errorf("ActionName = %q", out.ActionName)
}
if out.Token != "c-token-btn" {
t.Errorf("Token = %q", out.Token)
}
if out.MessageID != "om_msg_001" {
t.Errorf("MessageID = %q", out.MessageID)
}
if out.ChatID != "oc_chat_001" {
t.Errorf("ChatID = %q", out.ChatID)
}
if out.Host != "im_message" {
t.Errorf("Host = %q", out.Host)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
}
func TestProcessCardAction_FormSubmit(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_form_001",
"event_type": "card.action.trigger",
"create_time": "1776409469274"
},
"event": {
"operator": {"open_id": "ou_form_user"},
"token": "c-token-form",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "submit_btn",
"form_value": {"name": "test-user", "reason": "testing"},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_form_001",
"open_chat_id": "oc_chat_002"
}
}
}`
out := runCardAction(t, payload, nil)
if out.FormValue != `{"name":"test-user","reason":"testing"}` {
t.Errorf("FormValue = %q", out.FormValue)
}
if out.ActionTag != "button" {
t.Errorf("ActionTag = %q, want button", out.ActionTag)
}
}
func TestProcessCardAction_MultiSelect(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_ms_001",
"event_type": "card.action.trigger",
"create_time": "1776409469275"
},
"event": {
"operator": {"open_id": "ou_ms_user"},
"token": "c-token-ms",
"host": "im_message",
"action": {
"tag": "multi_select_static",
"value": {},
"name": "multi_select",
"options": ["opt_1", "opt_3"],
"checked": false
},
"context": {
"open_message_id": "om_ms_001",
"open_chat_id": "oc_chat_003"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Options != "opt_1,opt_3" {
t.Errorf("Options = %q, want opt_1,opt_3", out.Options)
}
if out.ActionTag != "multi_select_static" {
t.Errorf("ActionTag = %q", out.ActionTag)
}
}
func TestProcessCardAction_Input(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_input_001",
"event_type": "card.action.trigger",
"create_time": "1776409469276"
},
"event": {
"operator": {"open_id": "ou_input_user"},
"token": "c-token-input",
"host": "im_message",
"action": {
"tag": "input",
"value": {},
"name": "text_input",
"input_value": "hello world",
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_input_001",
"open_chat_id": "oc_chat_004"
}
}
}`
out := runCardAction(t, payload, nil)
if out.InputValue != "hello world" {
t.Errorf("InputValue = %q", out.InputValue)
}
if out.ActionTag != "input" {
t.Errorf("ActionTag = %q", out.ActionTag)
}
}
func TestProcessCardAction_DatePicker(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_date_001",
"event_type": "card.action.trigger",
"create_time": "1776409469277"
},
"event": {
"operator": {"open_id": "ou_date_user"},
"token": "c-token-date",
"host": "im_message",
"action": {
"tag": "date_picker",
"value": {},
"name": "date_selector",
"option": "2024-04-01 +0800",
"timezone": "Asia/Shanghai",
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_date_001",
"open_chat_id": "oc_chat_005"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Option != "2024-04-01 +0800" {
t.Errorf("Option = %q", out.Option)
}
if out.Timezone != "Asia/Shanghai" {
t.Errorf("Timezone = %q", out.Timezone)
}
}
func TestProcessCardAction_MalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: "card.action.trigger",
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processCardAction(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func TestProcessCardAction_MessageGetSuccess(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_ok",
"event_type": "card.action.trigger",
"create_time": "1776409469278"
},
"event": {
"operator": {"open_id": "ou_mg_user"},
"token": "c-token-mg",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "click"},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_001",
"open_chat_id": "oc_chat_mg"
}
}
}`
cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}`
mock := &mockAPIClient{resp: `{
"code": 0,
"msg": "success",
"data": {
"items": [{
"body": {"content": "` + escapeJSON(cardContent) + `"}
}]
}
}`}
out := runCardAction(t, payload, mock)
if out.CardContent == "" {
t.Error("CardContent should not be empty when message get succeeds")
}
}
func TestProcessCardAction_MessageGetErrorCode(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_ec",
"event_type": "card.action.trigger",
"create_time": "1776409469279"
},
"event": {
"operator": {"open_id": "ou_mg_user2"},
"token": "c-token-mg2",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_002",
"open_chat_id": "oc_chat_mg2"
}
}
}`
mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`}
out := runCardAction(t, payload, mock)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent)
}
}
func TestProcessCardAction_MessageGetFailure(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_fail",
"event_type": "card.action.trigger",
"create_time": "1776409469280"
},
"event": {
"operator": {"open_id": "ou_mg_user3"},
"token": "c-token-mg3",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_003",
"open_chat_id": "oc_chat_mg3"
}
}
}`
mock := &mockAPIClient{errResp: true}
out := runCardAction(t, payload, mock)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent)
}
}
func TestProcessCardAction_EmptyMessageID(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_no_msg",
"event_type": "card.action.trigger",
"create_time": "1776409469281"
},
"event": {
"operator": {"open_id": "ou_no_msg"},
"token": "c-token-nm",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "",
"open_chat_id": "oc_chat_nm"
}
}
}`
out := runCardAction(t, payload, nil)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent)
}
}
type mockAPIClient struct {
resp string
errResp bool
}
func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
if m.errResp {
return nil, context.DeadlineExceeded
}
return json.RawMessage(m.resp), nil
}
func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: "card.action.trigger",
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processCardAction(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out CardActionTriggerOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}
func escapeJSON(s string) string {
b, _ := json.Marshal(s)
return string(b[1 : len(b)-1])
}

View File

@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -55,10 +55,8 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
}
msg := envelope.Event.Message
var content string
if msg.MessageType == "interactive" {
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
} else {
content := msg.Content
if msg.MessageType != "interactive" {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),

View File

@@ -27,21 +27,6 @@ func Keys() []event.KeyDefinition {
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{"im.message.receive_v1"},
},
{
Key: "card.action.trigger",
DisplayName: "Card action",
Description: "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
EventType: "card.action.trigger",
SubscriptionType: event.SubTypeCallback,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})},
},
Process: processCardAction,
Scopes: []string{"im:message:readonly"},
AuthTypes: []string{"bot"},
SingleConsumer: true,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
for _, rk := range nativeIMKeys {

View File

@@ -7,7 +7,6 @@ package events
import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
@@ -18,7 +17,6 @@ func init() {
all := [][]event.KeyDefinition{
im.Keys(),
minutes.Keys(),
task.Keys(),
vc.Keys(),
whiteboard.Keys(),
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
// standard Lark V2 event envelope.
type TaskUpdateUserAccessV2Data struct {
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
}
var taskUpdateUserAccessCommitTypes = []string{
"task_create",
"task_deleted",
"task_summary_update",
"task_desc_update",
"task_assignees_update",
"task_followers_update",
"task_reminders_update",
"task_start_due_update",
"task_completed_update",
}

View File

@@ -1,32 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(
errs.SubtypeNetworkTransport,
"failed to subscribe task event",
).WithCause(err)
}
return nil, nil
}

View File

@@ -1,119 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
type stubAPIClient struct {
err error
method string
path string
body interface{}
calls int
}
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
s.method = method
s.path = path
s.body = body
s.calls++
if s.err != nil {
return nil, s.err
}
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
}
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
rt := &stubAPIClient{}
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
}
if cleanup != nil {
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
}
if rt.calls != 1 {
t.Fatalf("calls = %d, want 1", rt.calls)
}
if rt.method != "POST" {
t.Errorf("method = %q, want POST", rt.method)
}
if rt.path != taskSubscriptionPath {
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
}
if rt.body != nil {
t.Errorf("body = %#v, want nil", rt.body)
}
}
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
if p.Subtype != errs.SubtypeUnknown {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
}
}
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
rt := &stubAPIClient{err: wantErr}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != wantErr {
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
}
if !errors.Is(err, wantErr) {
t.Fatalf("err = %v, want %v", err, wantErr)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
}
}
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
cause := errors.New("connection reset")
rt := &stubAPIClient{err: cause}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, cause) {
t.Fatalf("err = %v, want cause %v", err, cause)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
if p.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
}
}

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package task registers Task-domain EventKeys.
package task
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
// Keys returns all Task-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeTaskUpdateUserAccessV2,
DisplayName: "Task updated",
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
EventType: eventTypeTaskUpdateUserAccessV2,
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
},
PreConsume: taskSubscriptionPreConsume,
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user", "bot"},
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
SingleConsumer: true,
},
}
}

View File

@@ -1,95 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"encoding/json"
"reflect"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
}
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
}
if def.Schema.Native == nil {
t.Fatal("Schema.Native is nil")
}
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
}
if def.Process != nil {
t.Fatal("Native Task EventKey must not set Process")
}
if def.PreConsume == nil {
t.Fatal("PreConsume is nil")
}
if !def.SingleConsumer {
t.Fatal("SingleConsumer = false, want true")
}
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
t.Errorf("Scopes = %#v", def.Scopes)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
var schema map[string]interface{}
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
taskGUID := eventProps["task_guid"].(map[string]interface{})
if got := taskGUID["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
eventTypes := eventProps["event_types"].(map[string]interface{})
items := eventTypes["items"].(map[string]interface{})
rawEnum, ok := items["enum"].([]interface{})
if !ok {
t.Fatalf("event_types item enum missing: %#v", items["enum"])
}
got := make(map[string]bool, len(rawEnum))
for _, v := range rawEnum {
got[v.(string)] = true
}
for _, want := range taskUpdateUserAccessCommitTypes {
if !got[want] {
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
}
}
}
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
const key = eventTypeTaskUpdateUserAccessV2
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}

View File

@@ -7,8 +7,8 @@ import "fmt"
// AbortError is returned by a Wrapper that wants to short-circuit the
// command chain (instead of calling next). The framework converts it
// to a typed errs.* error so the JSON envelope carries the structured
// fields agents expect.
// to an *output.ExitError with type "hook" so the JSON envelope carries
// the structured fields agents expect.
//
// HookName is the framework-namespaced name ("secaudit.approval"); the
// Registrar adds the plugin-name prefix automatically.

View File

@@ -7,9 +7,9 @@ import "fmt"
// CommandDeniedError is the structured error returned by a denyStub. Every
// pruned-command execution path -- direct invocation, alias expansion,
// internal call -- returns this exact type. The dispatcher converts it to a
// typed errs.* error; the Layer field carries the denial layer for the
// envelope.
// internal call -- returns this exact type. It is wire-compatible with the
// output.ExitError envelope via the Layer (== error.type) field and the
// detail map produced by ExitError().
//
// Layer values:
//

View File

@@ -1,47 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"encoding/json"
"fmt"
)
// FetchSubscribedCallbacks returns the app's currently subscribed callback names
// from application/get. On a successful fetch it always returns a non-nil slice
// (empty when callback_info is absent or lists no callbacks) so callers can
// distinguish "fetched, zero callbacks subscribed" — a definitive console state
// that must fail the precheck — from a fetch error (nil), which is a
// weak-dependency skip. Identity must be bot: the endpoint is app-level.
func FetchSubscribedCallbacks(ctx context.Context, client APIClient, appID string) ([]string, error) {
path := fmt.Sprintf("/open-apis/application/v6/applications/%s?lang=zh_cn", appID)
raw, err := client.CallAPI(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var envelope struct {
Data struct {
App struct {
CallbackInfo *struct {
SubscribedCallbacks []string `json:"subscribed_callbacks"`
} `json:"callback_info"`
} `json:"app"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("decode application response: %w", err)
}
// callback_info also carries callback_type (e.g. "websocket"); it is
// intentionally not parsed or validated. Feishu open-platform callbacks are
// delivered over WebSocket only (confirmed), matching the CLI's WebSocket
// event source, so subscribed_callbacks alone is sufficient for the precheck.
// Revisit and validate callback_type if non-WebSocket delivery ever appears.
callbacks := []string{}
if ci := envelope.Data.App.CallbackInfo; ci != nil {
callbacks = append(callbacks, ci.SubscribedCallbacks...)
}
return callbacks, nil
}

View File

@@ -1,101 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"encoding/json"
"errors"
"testing"
)
var errFakeFetch = errors.New("fake fetch error")
type fakeCallbackClient struct {
raw string
err error
}
func (f fakeCallbackClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
if f.err != nil {
return nil, f.err
}
return json.RawMessage(f.raw), nil
}
func TestFetchSubscribedCallbacks_ParsesList(t *testing.T) {
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket","subscribed_callbacks":["card.action.trigger","profile.view.get"]}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
want := []string{"card.action.trigger", "profile.view.get"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestFetchSubscribedCallbacks_NoCallbackInfo(t *testing.T) {
// A successful fetch with no callback_info means "zero callbacks subscribed",
// which must be a non-nil empty slice (distinct from a fetch error's nil) so
// the precheck reports a required callback as missing instead of skipping.
raw := `{"code":0,"data":{"app":{"app_id":"cli_x"}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestFetchSubscribedCallbacks_FetchError(t *testing.T) {
// A fetch error must return nil so the caller treats it as a weak-dependency skip.
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{err: errFakeFetch}, "cli_x")
if err == nil {
t.Fatal("expected error")
}
if got != nil {
t.Errorf("got %v, want nil on fetch error", got)
}
}
func TestFetchSubscribedCallbacks_CallbackInfoPresentButNull(t *testing.T) {
// callback_info present but subscribed_callbacks explicitly null → must be
// a non-nil empty slice so the precheck reports missing callbacks.
raw := `{"code":0,"data":{"app":{"callback_info":{"subscribed_callbacks":null}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is null")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestFetchSubscribedCallbacks_CallbackInfoPresentButOmitted(t *testing.T) {
// callback_info present but subscribed_callbacks omitted → same as null: non-nil empty.
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket"}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is omitted")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}

View File

@@ -6,6 +6,7 @@ package auth
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
@@ -21,10 +22,7 @@ var TokenRetryCodes = map[int]bool{
output.LarkErrTokenExpired: true,
}
// NeedAuthorizationError is the sentinel preserved in the Cause chain of the
// typed missing-UAT error so existing errors.As(&NeedAuthorizationError{})
// consumers keep matching after the construction site moved to the typed
// taxonomy. It is never surfaced on the wire on its own.
// NeedAuthorizationError is thrown when no valid UAT exists.
type NeedAuthorizationError struct {
UserOpenId string
}
@@ -34,31 +32,24 @@ func (e *NeedAuthorizationError) Error() string {
return fmt.Sprintf("%s (user: %s)", needUserAuthorizationMarker, e.UserOpenId)
}
// NewNeedUserAuthorizationError builds the typed *errs.AuthenticationError
// returned when no valid UAT exists for userOpenID. The Message keeps the
// need_user_authorization marker, the Hint converges on the same auth-login
// recovery vocabulary as the token-missing surface in internal/client, and the
// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for
// errors.As / errors.Is traversal.
func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"%s (user: %s)", needUserAuthorizationMarker, userOpenID).
WithUserOpenID(userOpenID).
WithHint("run: lark-cli auth login to re-authorize").
WithCause(&NeedAuthorizationError{UserOpenId: userOpenID})
}
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT
// failure. It matches the legacy *NeedAuthorizationError sentinel, which is
// preserved in the Cause chain of the typed missing-UAT error, so errors.As
// traverses into the typed *errs.AuthenticationError as well.
// failure, either as the original auth error or as a wrapped ExitError.
func IsNeedUserAuthorizationError(err error) bool {
if err == nil {
return false
}
var needAuthErr *NeedAuthorizationError
return errors.As(err, &needAuthErr)
if errors.As(err, &needAuthErr) {
return true
}
// Deprecated: legacy *output.ExitError / string-match branches; removed after typed migration.
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return strings.Contains(exitErr.Detail.Message, needUserAuthorizationMarker)
}
return strings.Contains(err.Error(), needUserAuthorizationMarker)
}
// SecurityPolicyError is preserved as a Go type alias so existing

View File

@@ -6,7 +6,7 @@ package auth
import (
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
func TestIsNeedUserAuthorizationError(t *testing.T) {
@@ -22,16 +22,15 @@ func TestIsNeedUserAuthorizationError(t *testing.T) {
}
})
t.Run("typed missing-UAT error carries sentinel in cause", func(t *testing.T) {
// The typed constructor preserves the legacy sentinel in the Cause
// chain, so errors.As traverses into it.
if !IsNeedUserAuthorizationError(NewNeedUserAuthorizationError("u_1")) {
t.Fatal("expected typed missing-UAT error to match via its cause chain")
t.Run("wrapped exit error", func(t *testing.T) {
err := output.ErrNetwork("API call failed: %s", &NeedAuthorizationError{})
if !IsNeedUserAuthorizationError(err) {
t.Fatal("expected wrapped ExitError to match")
}
})
t.Run("other error", func(t *testing.T) {
err := errs.NewNetworkError(errs.SubtypeNetworkTransport, "API call failed: timeout")
err := output.ErrNetwork("API call failed: timeout")
if IsNeedUserAuthorizationError(err) {
t.Fatal("expected unrelated error not to match")
}

View File

@@ -71,7 +71,7 @@ var refreshLocks sync.Map
func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, error) {
stored := GetStoredToken(opts.AppId, opts.UserOpenId)
if stored == nil {
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
}
status := TokenStatus(stored)
@@ -86,7 +86,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
return "", err
}
if refreshed == nil {
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
}
return refreshed.AccessToken, nil
}
@@ -99,7 +99,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
}
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
}
// refreshWithLock acquires a file lock before attempting to refresh the token.

View File

@@ -131,3 +131,31 @@ func requireInTrustedDirs(effectivePath string, trustedDirs []string, label stri
}
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}

View File

@@ -29,31 +29,3 @@ func checkOwnerUID(path, label string) error {
}
return nil
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}

View File

@@ -5,22 +5,7 @@
package binding
import (
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
func checkOwnerUID(path, label string) error {
return nil
}
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
if _, err := vfs.Stat(effectivePath); err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
return nil
}

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package binding
import (
"os"
"path/filepath"
"testing"
)
func TestAssertSecurePath_WindowsIgnoresSyntheticUnixPermissionBits(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secrets-getter.cmd")
if err := os.WriteFile(p, []byte("@echo off\r\n"), 0o600); err != nil {
t.Fatalf("write temp command: %v", err)
}
got, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "exec provider command",
AllowInsecurePath: false,
AllowReadableByOthers: true,
})
if err != nil {
t.Fatalf("unexpected error for Windows synthetic mode bits: %v", err)
}
if got != p {
t.Errorf("got %q, want %q", got, p)
}
}

View File

@@ -16,6 +16,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
// ─────────────────────────────────────────────────────────────────────────────
@@ -263,16 +264,19 @@ func TestWrapJSONResponseParseError_Nil(t *testing.T) {
// Cross-cutting: existing tests already in this file (kept and adjusted below).
// ─────────────────────────────────────────────────────────────────────────────
// TestWrapDoAPIError_UntypedErrorRoutesToNetwork pins that a plain untyped
// error (no embedded Problem, no JSON-decode chain) is NOT pass-through —
// only typed *errs.* values are. It routes to the network branch with the
// fallback transport subtype.
func TestWrapDoAPIError_UntypedErrorRoutesToNetwork(t *testing.T) {
got := WrapDoAPIError(errors.New("no access token available for user"))
// TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough pins that legacy
// *output.ExitError (auth/validation/api flavours) is NOT a problemCarrier
// and is therefore not pass-through — only typed *errs.* values are.
// Legacy values fall through to the network/JSON branches based on their
// inner shape.
func TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough(t *testing.T) {
// An *output.ErrAuth has no embedded Problem and no JSON-decode chain;
// it routes to the network branch with the fallback transport subtype.
got := WrapDoAPIError(output.ErrAuth("no access token available for user"))
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError for an untyped error, got %T (%v)", got, got)
t.Fatalf("expected *errs.NetworkError (legacy ExitError no longer pass-through), got %T (%v)", got, got)
}
// Sanity: not silently re-classified as JSON-decode.
var ie *errs.InternalError

View File

@@ -19,9 +19,11 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
@@ -52,11 +54,16 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s
if errors.As(err, &unavailableErr) {
return "", newTokenMissingError(as, unavailableErr)
}
// The credential chain already emits a typed *errs.AuthenticationError
// for the missing-UAT case (e.g. UAT refresh returned
// need_user_authorization), so it flows through unchanged: the
// outer-typed gate in cmd/root.go and the idempotent WrapDoAPIError
// both preserve its authentication category and exit 3.
// NeedAuthorizationError from the credential chain (e.g. UAT refresh
// returned need_user_authorization) must surface as typed
// AuthenticationError. Without this, WrapDoAPIError would wrap the
// raw err as NetworkError, and cmd/root.go's outer-typed gate would
// then skip PromoteAuthError — leaving the user with exit 4 and no
// auth-login hint instead of exit 3 typed authentication.
var needAuthErr *internalauth.NeedAuthorizationError
if errors.As(err, &needAuthErr) {
return "", errcompat.PromoteAuthError(needAuthErr)
}
return "", err
}
if result.Token == "" {
@@ -113,22 +120,24 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
//
// SDK Do() failures are normalised through WrapDoAPIError so every caller
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without
// each one remembering to wrap. WrapDoAPIError classifies a raw transport
// failure into a typed *errs.NetworkError / *errs.InternalError per the
// contract in errs/ERROR_CONTRACT.md. Errors that arrive already-classified
// (a typed *errs.* from resolveAccessToken's missing-credential paths or
// elsewhere) flow through unchanged.
// each one remembering to wrap. Today that wire shape is still the legacy
// *output.ExitError envelope (network / api_error); future framework-
// boundary migration flips WrapDoAPIError to typed *errs.NetworkError /
// *errs.InternalError per the contract in errs/ERROR_CONTRACT.md.
// Errors that arrive already-classified (legacy *output.ExitError from
// resolveAccessToken's missing-credential paths, or a typed *errs.*) flow
// through unchanged.
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
var opts []larkcore.RequestOptionFunc
token, err := c.resolveAccessToken(ctx, as)
if err != nil {
// WrapDoAPIError is idempotent on already-classified errors:
// the typed *errs.AuthenticationError that resolveAccessToken returns
// for missing tokens passes through with its auth category and exit 3
// intact, and any other typed *errs.* error from the credential chain
// survives the same way. Only stray untyped errors (raw fmt.Errorf)
// get the transport-or-internal fallback.
// the *output.ExitError that resolveAccessToken returns for missing
// tokens (via output.ErrAuth) passes through with its auth category
// and exit 3 intact, and any future typed *errs.* error from the
// credential chain survives the same way. Only stray untyped errors
// (raw fmt.Errorf) get the transport-or-internal fallback.
return nil, WrapDoAPIError(err)
}
if as.IsBot() {
@@ -153,7 +162,7 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
// Auth is resolved via Credential (same as DoSDKRequest). Security headers and
// any extra headers from opts are applied automatically.
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
// closed, and returned as a typed *errs.NetworkError — callers only receive successful responses.
// closed, and returned as an output.ErrNetwork — callers only receive successful responses.
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
cfg := buildConfig(opts)
@@ -323,10 +332,10 @@ func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore
//
// JSON parse failures are wrapped via WrapJSONResponseParseError so callers
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
// see a typed *errs.InternalError (invalid_response) instead of a bare
// fmt.Errorf — otherwise an empty or malformed page body would surface to the
// root handler as a plain-text "Error: ..." line and bypass the JSON stderr
// envelope contract.
// see an *output.ExitError envelope (api_error for malformed JSON, network
// for everything else) instead of a bare fmt.Errorf — otherwise an empty
// or malformed page body would surface to the root handler as a plain-text
// "Error: ..." line and bypass the JSON stderr envelope contract.
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
resp, err := c.DoAPI(ctx, request)
if err != nil {
@@ -341,7 +350,7 @@ func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interfa
// paginateLoop runs the core pagination loop. For each successful page (code == 0),
// it calls onResult if non-nil. It always accumulates and returns all raw page results.
func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opts PaginationOptions, onResult func(interface{}) error) ([]interface{}, error) {
func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opts PaginationOptions, onResult func(interface{})) ([]interface{}, error) {
var allResults []interface{}
var pageToken string
page := 0
@@ -390,9 +399,7 @@ func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opt
}
if onResult != nil {
if err := onResult(result); err != nil {
return allResults, err
}
onResult(result)
}
allResults = append(allResults, result)
@@ -445,31 +452,28 @@ func (c *APIClient) PaginateAll(ctx context.Context, request RawApiRequest, opts
// StreamPages fetches all pages and streams each page's list items via onItems.
// Returns the last page result (for error checking), whether any list items were found,
// and any network error. Use this for streaming formats (ndjson, table, csv).
func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onItems func([]interface{}) error, opts PaginationOptions) (result interface{}, hasItems bool, err error) {
func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onItems func([]interface{}), opts PaginationOptions) (result interface{}, hasItems bool, err error) {
totalItems := 0
results, loopErr := c.paginateLoop(ctx, request, opts, func(r interface{}) error {
results, loopErr := c.paginateLoop(ctx, request, opts, func(r interface{}) {
resultMap, ok := r.(map[string]interface{})
if !ok {
return nil
return
}
data, ok := resultMap["data"].(map[string]interface{})
if !ok {
return nil
return
}
arrayField := output.FindArrayField(data)
if arrayField == "" {
return nil
return
}
items, ok := data[arrayField].([]interface{})
if !ok {
return nil
return
}
totalItems += len(items)
if err := onItems(items); err != nil {
return err
}
onItems(items)
hasItems = true
return nil
})
if loopErr != nil {
return nil, false, loopErr

View File

@@ -124,9 +124,8 @@ func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) {
Method: "GET",
URL: "/open-apis/contact/v3/users/u123",
As: "bot",
}, func(items []interface{}) error {
}, func(items []interface{}) {
t.Error("onItems should not be called for non-batch API")
return nil
}, PaginationOptions{})
if err != nil {
@@ -169,9 +168,8 @@ func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
Method: "GET",
URL: "/open-apis/contact/v3/users",
As: "bot",
}, func(items []interface{}) error {
}, func(items []interface{}) {
streamedItems = append(streamedItems, items...)
return nil
}, PaginationOptions{})
if err != nil {
@@ -191,58 +189,6 @@ func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
}
}
func TestStreamPages_OnItemsErrorStopsPagination(t *testing.T) {
apiCalls := 0
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
apiCalls++
if apiCalls == 1 {
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": true,
"page_token": "next",
},
}), nil
}
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "2"}},
"has_more": false,
},
}), nil
})
ac, _ := newTestAPIClient(t, rt)
sentinel := errors.New("stop streaming")
var streamedItems []interface{}
result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/contact/v3/users",
As: "bot",
}, func(items []interface{}) error {
streamedItems = append(streamedItems, items...)
return sentinel
}, PaginationOptions{PageDelay: 0})
if !errors.Is(err, sentinel) {
t.Fatalf("err = %v, want sentinel", err)
}
if result != nil {
t.Fatalf("result = %#v, want nil when callback stops pagination", result)
}
if hasItems {
t.Fatal("hasItems = true, want false when callback stops before returning")
}
if apiCalls != 1 {
t.Fatalf("apiCalls = %d, want early stop after first page", apiCalls)
}
if len(streamedItems) != 1 {
t.Fatalf("streamedItems = %d, want first page only", len(streamedItems))
}
}
func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
apiCalls := 0
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
@@ -528,7 +474,8 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T
// TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError pins that
// the missing-token path of resolveAccessToken returns the typed
// *errs.AuthenticationError{Subtype: TokenMissing}.
// *errs.AuthenticationError{Subtype: TokenMissing} rather than the legacy
// *output.ExitError envelope.
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
@@ -553,22 +500,24 @@ func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T
}
}
// needAuthTokenResolver mirrors the production credential chain: the
// missing-UAT case is constructed typed at the source (internal/auth) and
// carries the legacy *NeedAuthorizationError sentinel in its Cause chain. It
// must surface as a typed AuthenticationError and flow through resolveAccessToken
// and WrapDoAPIError unchanged (never mis-classified as NetworkError).
// needAuthTokenResolver returns *internalauth.NeedAuthorizationError to
// exercise the P1 regression path: a credential chain that signals
// "user must re-authorize" must surface as typed AuthenticationError, not
// fall through to the generic err return which WrapDoAPIError would then
// wrap as NetworkError (the outer-typed dispatcher gate would then skip
// PromoteAuthError and the user would see exit 4 with no auth-login hint).
type needAuthTokenResolver struct {
userOpenID string
}
func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return nil, internalauth.NewNeedUserAuthorizationError(f.userOpenID)
return nil, &internalauth.NeedAuthorizationError{UserOpenId: f.userOpenID}
}
// TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication
// pins that the typed missing-UAT error from the credential chain reaches the
// caller as a typed AuthenticationError with the marker and sentinel intact.
// is the codex P1 regression test: without this branch, the credential
// chain's NeedAuthorizationError would propagate raw and WrapDoAPIError
// would mis-classify it as NetworkError.
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
@@ -674,7 +623,7 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint
// preserved on Problem.Hint. Pagination / cmd/api / cmd/service callers see
// the typed JSON stderr envelope (exit 5/internal) — wire `type` is
// "internal".
// "internal", not the legacy "api_error".
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{

View File

@@ -4,6 +4,7 @@
package client
import (
"context"
"fmt"
"io"
@@ -18,6 +19,33 @@ type PaginationOptions struct {
Identity core.Identity // identity passed to checkErr; defaults to AsUser when empty
}
// PaginateWithJq aggregates all pages, checks for API errors, then applies a jq filter.
// If checkErr detects an error, the raw result is printed as JSON before returning the error.
func PaginateWithJq(ctx context.Context, ac *APIClient, request RawApiRequest,
jqExpr string, out io.Writer, pagOpts PaginationOptions,
checkErr func(interface{}, core.Identity) error) error {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
// Identity resolution honors pagOpts.Identity first, then the request's
// own identity, and only falls back to AsUser when neither caller
// supplied one. Without checking request.As, bot/auto requests would
// always be classified as user identity for checkErr.
identity := pagOpts.Identity
if identity == "" {
identity = request.As
}
if identity == "" || identity == core.AsAuto {
identity = core.AsUser
}
if apiErr := checkErr(result, identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.JqFilter(out, result, jqExpr)
}
func mergePagedResults(w io.Writer, results []interface{}) interface{} {
if len(results) == 0 {
return map[string]interface{}{}

View File

@@ -41,26 +41,6 @@ type ResponseOptions struct {
CheckError func(result interface{}, identity core.Identity) error
}
// httpStatusError classifies an HTTP error response by status when the body
// carries no usable business error: 5xx → NetworkError (server tier), 404 →
// APIError/not_found, any other 4xx → APIError/unknown. Used wherever a
// status >= 400 must not be swallowed — a non-JSON body, an unparseable body,
// or a JSON body whose business code is 0.
func httpStatusError(status int, rawBody []byte) error {
body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(rawBody)), 500)
if status >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer,
"HTTP %d: %s", status, body).
WithCode(status)
}
subtype := errs.SubtypeUnknown
if status == 404 {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "HTTP %d: %s", status, body).
WithCode(status)
}
// HandleResponse routes a raw *larkcore.ApiResp to the appropriate output:
// 1. If Content-Type is JSON, check for business errors first (even with --output).
// 2. If --output is set and response is not a JSON error, save to file.
@@ -82,64 +62,50 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
}
}
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error
// directly instead of falling through to the binary-save path.
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly
// instead of falling through to the binary-save path.
// 5xx → typed NetworkError (server/transport tier); 4xx → typed APIError (client error).
if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" {
return httpStatusError(resp.StatusCode, resp.RawBody)
body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer,
"HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == 404 {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
// JSON responses: always check for business errors before saving.
if IsJSONContentType(ct) || ct == "" {
result, err := ParseJSONResponse(resp)
if err != nil {
// An unparseable / empty body on an HTTP error (common with a
// missing Content-Type) must be classified by status, not reported
// as an internal decode failure, matching the non-JSON branch above.
if resp.StatusCode >= 400 {
return httpStatusError(resp.StatusCode, resp.RawBody)
}
return WrapJSONResponseParseError(err, resp.RawBody)
}
if apiErr := check(result, identity); apiErr != nil {
return apiErr
}
// CheckResponse treats business code 0 as success, so a 4xx/5xx whose
// JSON body omits a non-zero code would otherwise be served as a
// successful result. Classify by HTTP status so it is never swallowed.
if resp.StatusCode >= 400 {
return httpStatusError(resp.StatusCode, resp.RawBody)
// Content safety scanning
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if opts.OutputPath != "" {
// File downloads keep the existing raw-response scan path because the
// saved payload is the API response body, not the success envelope.
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
}
if opts.JqExpr != "" || opts.Format == output.FormatJSON {
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: opts.CommandPath,
Identity: string(identity),
JqExpr: opts.JqExpr,
Out: opts.Out,
ErrOut: opts.ErrOut,
})
}
// Content safety scanning for non-JSON presentation formats.
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}
if opts.JqExpr != "" {
return output.JqFilter(opts.Out, result, opts.JqExpr)
}
output.FormatValue(opts.Out, result, opts.Format)
return nil
}

View File

@@ -5,7 +5,6 @@ package client
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
@@ -17,7 +16,6 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
@@ -209,54 +207,15 @@ func TestHandleResponse_JSON(t *testing.T) {
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Identity: core.AsBot,
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse failed: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
}
if got["ok"] != true {
t.Fatalf("ok = %v, want true; output: %s", got["ok"], out.String())
}
if got["identity"] != "bot" {
t.Fatalf("identity = %v, want bot; output: %s", got["identity"], out.String())
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code field: %s", out.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %T, want object; output: %s", got["data"], out.String())
}
if data["id"] != "1" {
t.Fatalf("data.id = %v, want 1; output: %s", data["id"], out.String())
}
}
func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) {
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Identity: core.AsBot,
JqExpr: ".data.id",
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse failed: %v", err)
}
if strings.TrimSpace(out.String()) != "1" {
t.Fatalf("jq output = %q, want %q", out.String(), "1")
if !bytes.Contains(out.Bytes(), []byte(`"code"`)) {
t.Errorf("expected JSON output, got: %s", out.String())
}
}
@@ -274,12 +233,6 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
if err == nil {
t.Error("expected error for non-zero code")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if strings.Contains(out.String(), `"ok": true`) || strings.Contains(out.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", out.String())
}
}
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
@@ -372,76 +325,6 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) {
}
}
// TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed pins that an HTTP
// status error whose JSON body omits a non-zero business code (e.g. 400 +
// {"code":0,...}) still surfaces a typed error. CheckResponse treats code 0 as
// success, so without the HTTP-status fallback a 4xx would be served as a
// successful result and exit 0.
func TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed(t *testing.T) {
resp := newApiRespWithStatus(400, []byte(`{"code":0,"msg":"bad request"}`),
map[string]string{"Content-Type": "application/json"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatalf("HTTP 400 with code:0 body must not be swallowed; got out=%q err=nil", out.String())
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("expected *errs.APIError, got %T", err)
}
if !strings.Contains(err.Error(), "HTTP 400") {
t.Errorf("expected 'HTTP 400' in error, got: %s", err.Error())
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
}
}
// TestHandleResponse_NoContentTypeError_404 pins that a 404 with an empty body
// and no Content-Type header — which falls into the JSON branch and fails to
// parse — is classified by HTTP status (api/not_found), not reported as an
// internal decode failure.
func TestHandleResponse_NoContentTypeError_404(t *testing.T) {
resp := newApiRespWithStatus(404, []byte(""), nil)
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 404 with empty body and no Content-Type")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("expected *errs.APIError, got %T", err)
}
if apiErr != nil && apiErr.Subtype != errs.SubtypeNotFound {
t.Errorf("subtype = %q, want not_found", apiErr.Subtype)
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
}
}
// TestHandleResponse_NoContentTypeError_502 pins that a 5xx with a non-JSON
// body and no Content-Type is classified as a NetworkError by status, not an
// internal decode failure.
func TestHandleResponse_NoContentTypeError_502(t *testing.T) {
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), nil)
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 502 with non-JSON body and no Content-Type")
}
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Errorf("expected *errs.NetworkError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d) for 5xx, got %d", output.ExitNetwork, output.ExitCodeOf(err))
}
}
func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()

View File

@@ -34,24 +34,10 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
)
// Source identifies how a command entered the repository-owned command tree.
type Source string
const (
SourceBuiltin Source = "builtin"
SourceShortcut Source = "shortcut"
SourceService Source = "service"
)
const (
// domainAnnotationKey is the cobra Annotation key for the business domain.
// Kept distinct from cmdutil.* keys so this package can evolve without
// disturbing existing readers.
domainAnnotationKey = "cmdmeta.domain"
sourceAnnotationKey = "cmdmeta.source"
generatedAnnotationKey = "cmdmeta.generated"
)
// domainAnnotationKey is the cobra Annotation key for the business domain.
// Kept distinct from cmdutil.* keys so this package can evolve without
// disturbing existing readers.
const domainAnnotationKey = "cmdmeta.domain"
// Meta groups the three command-level metadata axes consumed by the policy
// engine and hook selectors.
@@ -107,24 +93,6 @@ func SetDomain(cmd *cobra.Command, domain string) {
cmd.Annotations[domainAnnotationKey] = domain
}
// SetSource stores the command source on a single command. The generated flag
// is written explicitly so child commands can opt out of inherited service
// metadata.
func SetSource(cmd *cobra.Command, source Source, generated bool) {
if source == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[sourceAnnotationKey] = string(source)
if generated {
cmd.Annotations[generatedAnnotationKey] = "true"
} else {
cmd.Annotations[generatedAnnotationKey] = "false"
}
}
// Domain returns the nearest-ancestor domain for the command. Empty string
// when no ancestor has the annotation -- this is the "unknown" state the
// policy engine must treat as ALLOW.
@@ -140,33 +108,6 @@ func Domain(cmd *cobra.Command) string {
return ""
}
// SourceOf returns the nearest-ancestor command source.
func SourceOf(cmd *cobra.Command) (Source, bool) {
for c := cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if v := c.Annotations[sourceAnnotationKey]; v != "" {
return Source(v), true
}
}
return "", false
}
// Generated returns the nearest generated annotation. An explicit false on a
// child command stops inheritance from a generated parent.
func Generated(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if v, ok := c.Annotations[generatedAnnotationKey]; ok {
return v == "true"
}
}
return false
}
// Risk returns the nearest-ancestor risk level (via cmdutil.GetRisk).
// ok=false signals "unknown" -- the policy engine treats this as
// fail-closed (deny with risk_not_annotated) whenever a Rule without

View File

@@ -141,19 +141,3 @@ func TestSetDomain_emptyIsNoop(t *testing.T) {
t.Fatalf("Domain(child) = %q, want inherited 'docs'", got)
}
}
func TestSourceGenerated_childFalseStopsParentGeneratedInheritance(t *testing.T) {
parent := &cobra.Command{Use: "docs"}
child := &cobra.Command{Use: "+fetch"}
parent.AddCommand(child)
cmdmeta.SetSource(parent, cmdmeta.SourceService, true)
cmdmeta.SetSource(child, cmdmeta.SourceShortcut, false)
if source, ok := cmdmeta.SourceOf(child); !ok || source != cmdmeta.SourceShortcut {
t.Fatalf("SourceOf(child) = (%q,%v), want (shortcut,true)", source, ok)
}
if cmdmeta.Generated(child) {
t.Fatal("Generated(child) = true, want false")
}
}

View File

@@ -4,13 +4,13 @@
package cmdpolicy_test
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -168,15 +168,10 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
}
}
// Apply returns a typed *errs.ValidationError that exposes BOTH paths
// consumers rely on:
// 1. cmd/root.go's envelope writer (errs.ProblemOf / failed_precondition
// subtype + exit code 2)
// 2. in-process consumers extracting the platform.CommandDeniedError as
// the typed error's Cause via errors.As
//
// The policy metadata (layer / policy_source / rule_name / reason_code)
// is folded into the Hint text rather than a separate detail map.
// Apply with the wrapped *output.ExitError exposes BOTH paths consumers
// rely on:
// 1. cmd/root.go's envelope writer (errors.As on *output.ExitError)
// 2. in-process consumers extracting the platform.CommandDeniedError
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
root := buildTree()
denied := map[string]cmdpolicy.Denial{
@@ -196,33 +191,31 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
t.Fatalf("denied command should return error")
}
// Path 1: typed-envelope view. The denial is a failed_precondition
// ValidationError so cmd/root.go renders the structured envelope and
// the process exits 2 (ExitValidation).
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error chain must contain *errs.ValidationError, got %T", err)
// Path 1: envelope-writer view.
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error chain must contain *output.ExitError, got %T", err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
if exitErr.Detail == nil {
t.Fatalf("ExitError.Detail required for envelope to render")
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
if exitErr.Detail.Type != "command_denied" {
t.Errorf("envelope error.type = %q, want command_denied", exitErr.Detail.Type)
}
// The policy metadata is folded into the Hint text: reason_code,
// policy_source, and rule_name must all be discoverable there.
if !strings.Contains(ve.Hint, "write_not_allowed") {
t.Errorf("hint must carry reason_code write_not_allowed, got %q", ve.Hint)
// JSON envelope shape: detail.reason_code must be present and
// match the closed enum.
detailMap, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("envelope detail should be map[string]any, got %T", exitErr.Detail.Detail)
}
if !strings.Contains(ve.Hint, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
if detailMap["reason_code"] != "write_not_allowed" {
t.Errorf("detail.reason_code = %v, want write_not_allowed", detailMap["reason_code"])
}
if !strings.Contains(ve.Hint, "secaudit-policy") {
t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint)
if detailMap["policy_source"] != "plugin:secaudit" {
t.Errorf("detail.policy_source = %v, want plugin:secaudit", detailMap["policy_source"])
}
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError
// is preserved as the Cause so errors.As still reaches it.
// Path 2: in-process typed-error view.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("error chain must expose *platform.CommandDeniedError")
@@ -230,6 +223,21 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
if cd.Path != "docs/+update" || cd.ReasonCode != "write_not_allowed" {
t.Errorf("CommandDeniedError = %+v", cd)
}
// Envelope round-trip sanity (the actual JSON cmd/root.go would emit).
var buf strings.Builder
output.WriteErrorEnvelope(&buf, exitErr, "user")
if !strings.Contains(buf.String(), `"type": "command_denied"`) {
t.Errorf("envelope JSON missing type=command_denied, got:\n%s", buf.String())
}
if !strings.Contains(buf.String(), `"reason_code": "write_not_allowed"`) {
t.Errorf("envelope JSON missing reason_code, got:\n%s", buf.String())
}
// Round-trip parse to verify it's well-formed JSON.
var parsed map[string]any
if err := json.Unmarshal([]byte(buf.String()), &parsed); err != nil {
t.Fatalf("envelope JSON malformed: %v\n%s", err, buf.String())
}
}
// Regression: a pure parent group carrying AnnotationPureGroup must be

View File

@@ -6,8 +6,8 @@ package cmdpolicy
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/output"
)
// Apply walks the command tree and installs denyStubs for every path in
@@ -24,11 +24,12 @@ import (
// cobra would intercept the call
// with "missing required flag"
// before we can return our error
// 3. cmd.RunE = denyStub(denial) -- returns a typed
// *errs.ValidationError so
// 3. cmd.RunE = denyStub(denial) -- returns *output.ExitError so
// cmd/root.go's envelope writer
// emits structured JSON; the
// wrapped error chain still
// emits structured JSON (with
// error.type = denial.Layer and
// detail.reason_code = ReasonCode);
// the wrapped error chain still
// exposes *platform.CommandDeniedError
// via errors.As for in-process
// consumers
@@ -111,17 +112,42 @@ func CommandDeniedFromDenial(path string, d Denial) *platform.CommandDeniedError
}
}
// BuildDenialError is the default typed error for user-layer denials:
// Message comes from CommandDeniedError.Error(); the policy layer, source,
// rule name, and reason code are folded into the Hint. The
// *platform.CommandDeniedError is preserved as the Cause so errors.As
// works for in-process consumers.
func BuildDenialError(path string, d Denial) *errs.ValidationError {
// DenialDetailMap is the canonical detail.* shape every `command_denied`
// envelope shares (see docs/extension/reason-codes.md). Use it as
// ErrDetail.Detail when constructing an envelope outside BuildDenialError.
func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any {
return map[string]any{
"path": cd.Path,
"layer": cd.Layer,
"policy_source": cd.PolicySource,
"rule_name": cd.RuleName,
"reason_code": cd.ReasonCode,
"reason": cd.Reason,
}
}
// BuildDenialError is the default envelope for user-layer denials:
// Message comes from CommandDeniedError.Error(), no Hint. Callers that
// need a custom Message or an independent Hint (strict-mode) should
// compose CommandDeniedFromDenial + DenialDetailMap themselves.
//
// Deprecated: BuildDenialError produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// use it — denial signals should move to a typed *errs.XxxError (a dedicated
// typed Error for policy denial is tracked for the cmdpolicy migration PR).
// This helper is retained only while existing call sites are migrated; it
// will be removed once they have moved to the typed surface.
func BuildDenialError(path string, d Denial) *output.ExitError {
cd := CommandDeniedFromDenial(path, d)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", cd.Error()).
WithHint("denied by %s policy (source %s, rule %q, reason_code %s); adjust the policy configuration to allow this command",
cd.Layer, cd.PolicySource, cd.RuleName, cd.ReasonCode).
WithCause(cd)
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "command_denied",
Message: cd.Error(),
Detail: DenialDetailMap(cd),
},
Err: cd,
}
}
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go
@@ -195,9 +221,9 @@ func installDenyStub(cmd *cobra.Command, path string, d Denial) bool {
denial := d // capture by value for the closure
cmd.RunE = func(c *cobra.Command, args []string) error {
// The typed message carries the user-facing semantic ("a command
// was denied"); the hint carries the layer / source / rule
// distinction ("policy" vs "strict_mode") for debugging.
// error.type is the user-facing semantic ("a command was denied by
// policy"). detail.layer carries the implementation distinction
// ("policy" vs "strict_mode") for debugging.
return BuildDenialError(path, denial)
}
// Clear any pre-existing Run hook: cobra prefers RunE when both are

View File

@@ -9,9 +9,9 @@
// aggregation), which the Apply step consumes to install denyStubs.
//
// This package only implements the user-layer half. Strict-mode is handled
// by cmd/prune.go, which produces typed validation errors of the same shape
// (failed_precondition, *platform.CommandDeniedError preserved as Cause) so
// external agents see a uniform envelope regardless of which layer rejected
// by cmd/prune.go, which produces command_denied envelopes of the same
// shape via BuildDenialError so external agents can dispatch on
// detail.layer / reason_code uniformly regardless of which layer rejected
// the call.
package cmdpolicy

View File

@@ -10,9 +10,9 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/output"
)
// The envelope's policy_source must never leak the absolute home path.
@@ -39,26 +39,25 @@ func TestEnvelope_yamlPolicySourceDoesNotLeakHomePath(t *testing.T) {
cmdpolicy.Apply(root, denied)
err := leaf.RunE(leaf, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected denial *errs.ValidationError, got %T %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected denial ExitError, got %v", err)
}
// The policy source is folded into the Hint as "yaml" -- the bare
// kind, never the absolute path.
if !strings.Contains(ve.Hint, "source yaml") {
t.Errorf("hint must carry policy_source %q (no path leak), got %q", "yaml", ve.Hint)
detail := exitErr.Detail.Detail.(map[string]any)
src, _ := detail["policy_source"].(string)
if src != "yaml" {
t.Errorf("policy_source = %q, want %q (no path leak)", src, "yaml")
}
// rule_name carries the disambiguating identifier.
if !strings.Contains(ve.Hint, "my-readonly-rule") {
t.Errorf("hint must carry rule_name my-readonly-rule, got %q", ve.Hint)
if detail["rule_name"] != "my-readonly-rule" {
t.Errorf("rule_name = %v, want my-readonly-rule", detail["rule_name"])
}
// Direct privacy probe: the absolute home path must not appear
// anywhere in the user-facing message OR hint text.
if strings.Contains(ve.Message, "/Users/alice") {
t.Errorf("error message must not leak '/Users/alice', got %q", ve.Message)
}
if strings.Contains(ve.Hint, "/Users/alice") {
t.Errorf("error hint must not leak '/Users/alice', got %q", ve.Hint)
// Direct probe: the absolute path must not appear anywhere in the
// envelope detail (key OR value).
for k, v := range detail {
if strings.Contains(k, "/Users/alice") || strings.Contains(asString(v), "/Users/alice") {
t.Errorf("envelope detail must not leak '/Users/alice', found in %s = %v", k, v)
}
}
}
@@ -81,14 +80,17 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
cmdpolicy.Apply(root, denied)
err := leaf.RunE(leaf, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError")
}
// The plugin name IS surfaced (in-binary, part of the contract): it
// must appear in the Hint so an integrator debugging a denial knows
// which plugin fired.
if !strings.Contains(ve.Hint, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
detail := exitErr.Detail.Detail.(map[string]any)
if detail["policy_source"] != "plugin:secaudit" {
t.Errorf("policy_source = %v, want plugin:secaudit", detail["policy_source"])
}
}
func asString(v any) string {
s, _ := v.(string)
return s
}

View File

@@ -4,22 +4,39 @@
package cmdutil
import (
"github.com/larksuite/cli/errs"
"fmt"
"github.com/larksuite/cli/internal/output"
)
// RequireConfirmation constructs a typed *errs.ConfirmationRequiredError
// (exit code ExitConfirmationRequired) carrying the risk level and action as
// typed extension fields. Used by both shortcut and service command execution
// paths when a statically high-risk-write operation has not been confirmed
// with --yes.
// RequireConfirmation constructs a confirmation_required error with exit code
// ExitConfirmationRequired and a structured Risk envelope. Used by both
// shortcut and service command execution paths when a statically
// high-risk-write operation has not been confirmed with --yes.
//
// action identifies the operation for the agent (e.g. "mail +send",
// "drive.files.delete"). The envelope does not carry a pre-built retry
// command: agents already know their original invocation and only need to
// append --yes per the hint, which keeps the protocol free of shell-quoting
// pitfalls.
// Deprecated: RequireConfirmation produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// use it — confirmation-required signals should move to typed
// *errs.ConfirmationRequiredError carrying the same agent-protocol metadata
// (level/action) as typed extension fields. This helper is retained only
// while existing call sites are migrated; it will be removed once they have
// moved to the typed surface.
func RequireConfirmation(action string) error {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action).
WithHint("add --yes to confirm")
return &output.ExitError{
Code: output.ExitConfirmationRequired,
Detail: &output.ErrDetail{
Type: "confirmation_required",
Message: fmt.Sprintf("%s requires confirmation", action),
Hint: "add --yes to confirm",
Risk: &output.RiskDetail{
Level: RiskHighRiskWrite,
Action: action,
},
},
}
}

View File

@@ -9,50 +9,53 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
func TestRequireConfirmation_TypedShape(t *testing.T) {
func TestRequireConfirmation_EnvelopeShape(t *testing.T) {
err := RequireConfirmation("drive +delete")
if err == nil {
t.Fatal("expected non-nil error")
}
var cre *errs.ConfirmationRequiredError
if !errors.As(err, &cre) {
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if cre.Category != errs.CategoryConfirmation {
t.Errorf("Category = %q, want %q", cre.Category, errs.CategoryConfirmation)
if exitErr.Code != output.ExitConfirmationRequired {
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitConfirmationRequired)
}
if cre.Subtype != errs.SubtypeConfirmationRequired {
t.Errorf("Subtype = %q, want %q", cre.Subtype, errs.SubtypeConfirmationRequired)
if exitErr.Detail == nil {
t.Fatal("Detail is nil")
}
if got := output.ExitCodeOf(err); got != output.ExitConfirmationRequired {
t.Errorf("ExitCodeOf = %d, want %d", got, output.ExitConfirmationRequired)
d := exitErr.Detail
if d.Type != "confirmation_required" {
t.Errorf("Type = %q, want confirmation_required", d.Type)
}
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
if !strings.Contains(d.Message, "drive +delete") || !strings.Contains(d.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", d.Message)
}
if cre.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
if d.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want 'add --yes to confirm'", d.Hint)
}
if cre.Risk != errs.RiskHighRiskWrite {
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
if d.Risk == nil {
t.Fatal("Risk is nil")
}
if cre.Action != "drive +delete" {
t.Errorf("Action = %q, want drive +delete", cre.Action)
if d.Risk.Level != "high-risk-write" {
t.Errorf("Risk.Level = %q, want high-risk-write", d.Risk.Level)
}
if d.Risk.Action != "drive +delete" {
t.Errorf("Risk.Action = %q, want drive +delete", d.Risk.Action)
}
}
func TestRequireConfirmation_JSONShape(t *testing.T) {
err := RequireConfirmation("mail +send")
var cre *errs.ConfirmationRequiredError
if !errors.As(err, &cre) {
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
raw, mErr := json.Marshal(cre)
raw, mErr := json.Marshal(exitErr.Detail)
if mErr != nil {
t.Fatalf("marshal: %v", mErr)
}
@@ -67,14 +70,18 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
t.Errorf("unexpected fix_command present in JSON: %s", raw)
}
if back["risk"] != "high-risk-write" {
t.Errorf("risk in JSON = %v", back["risk"])
risk, ok := back["risk"].(map[string]interface{})
if !ok {
t.Fatalf("risk block missing in JSON: %s", raw)
}
if back["action"] != "mail +send" {
t.Errorf("action in JSON = %v", back["action"])
if risk["level"] != "high-risk-write" {
t.Errorf("risk.level in JSON = %v", risk["level"])
}
if risk["action"] != "mail +send" {
t.Errorf("risk.action in JSON = %v", risk["action"])
}
// Action-only protocol: no UpgradedBy / fix_command / upgraded_by leak.
if _, has := back["upgraded_by"]; has {
if _, has := risk["upgraded_by"]; has {
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
}
}

View File

@@ -8,7 +8,6 @@ import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
_ "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
@@ -108,9 +107,9 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
if err == nil {
t.Fatal("Config() error = nil, want non-nil")
}
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("Config() error type = %T, want *errs.ConfigError", err)
t.Fatalf("Config() error type = %T, want *core.ConfigError", err)
}
if cfgErr.Message != `profile "missing" not found` {
t.Fatalf("Config() error message = %q, want %q", cfgErr.Message, `profile "missing" not found`)

View File

@@ -10,8 +10,8 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
@@ -42,41 +42,26 @@ func ValidateFileFlag(file, params, data, outputPath string, pageAll bool, httpM
_, filePath, isStdin := ParseFileFlag(file, "file")
if !isStdin && filePath == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: empty file path").
WithParam("--file")
return output.ErrValidation("--file: empty file path")
}
if outputPath != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --output are mutually exclusive").WithParams(
errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --output"},
errs.InvalidParam{Name: "--output", Reason: "mutually exclusive with --file"},
)
return output.ErrValidation("--file and --output are mutually exclusive")
}
if pageAll {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --page-all are mutually exclusive").WithParams(
errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --page-all"},
errs.InvalidParam{Name: "--page-all", Reason: "mutually exclusive with --file"},
)
return output.ErrValidation("--file and --page-all are mutually exclusive")
}
if isStdin && data == "-" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --data cannot both read from stdin").WithParams(
errs.InvalidParam{Name: "--file", Reason: "only one flag may read from stdin"},
errs.InvalidParam{Name: "--data", Reason: "only one flag may read from stdin"},
)
return output.ErrValidation("--file and --data cannot both read from stdin")
}
if isStdin && params == "-" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --params cannot both read from stdin").WithParams(
errs.InvalidParam{Name: "--file", Reason: "only one flag may read from stdin"},
errs.InvalidParam{Name: "--params", Reason: "only one flag may read from stdin"},
)
return output.ErrValidation("--file and --params cannot both read from stdin")
}
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file requires POST, PUT, PATCH, or DELETE method").
WithParam("--file").
WithHint("file upload only applies to write methods; remove --file for read methods")
return output.ErrValidation("--file requires POST, PUT, PATCH, or DELETE method")
}
return nil
@@ -98,35 +83,25 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
if isStdin {
if stdin == nil {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "--file: stdin is not available").
WithParam("--file").
WithHint("pipe the file content to stdin, or pass a file path instead of \"-\"")
return nil, output.ErrValidation("--file: stdin is not available")
}
data, err := io.ReadAll(stdin)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read stdin: %v", err).
WithParam("--file").
WithCause(err)
return nil, output.ErrValidation("--file: failed to read stdin: %v", err)
}
if len(data) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: stdin is empty").
WithParam("--file").
WithHint("pipe non-empty file content to stdin")
return nil, output.ErrValidation("--file: stdin is empty")
}
fd.AddFile(fieldName, bytes.NewReader(data))
} else {
f, err := fileIO.Open(filePath)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot open file: %s", filePath).
WithParam("--file").
WithCause(err)
return nil, output.ErrValidation("cannot open file: %s", filePath)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read %s: %v", filePath, err).
WithParam("--file").
WithCause(err)
return nil, output.ErrValidation("--file: failed to read %s: %v", filePath, err)
}
fd.AddFile(fieldName, bytes.NewReader(data))
}

View File

@@ -5,49 +5,14 @@ package cmdutil
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
// failingReader always errors on Read, to exercise stdin read-failure paths.
type failingReader struct{ err error }
func (r *failingReader) Read([]byte) (int, error) { return 0, r.err }
// requireFileValidationError asserts err is a typed *errs.ValidationError with
// the expected subtype, exit code 2 (legacy ErrValidation parity), and a
// param diagnostic referencing --file (either Param or one of Params).
func requireFileValidationError(t *testing.T, err error, wantSubtype errs.Subtype) *errs.ValidationError {
t.Helper()
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if valErr.Subtype != wantSubtype {
t.Errorf("subtype = %q, want %q", valErr.Subtype, wantSubtype)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation, legacy parity)", got, output.ExitValidation)
}
mentionsFile := valErr.Param == "--file"
for _, p := range valErr.Params {
if p.Name == "--file" {
mentionsFile = true
}
}
if !mentionsFile {
t.Errorf("expected --file in Param/Params, got Param=%q Params=%v", valErr.Param, valErr.Params)
}
return valErr
}
func TestParseFileFlag(t *testing.T) {
tests := []struct {
name string
@@ -257,7 +222,6 @@ func TestValidateFileFlag(t *testing.T) {
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
}
requireFileValidationError(t, err, errs.SubtypeInvalidArgument)
})
}
}
@@ -284,19 +248,6 @@ func TestBuildFormdata(t *testing.T) {
if !strings.Contains(err.Error(), "stdin is not available") {
t.Errorf("error = %q, want containing %q", err.Error(), "stdin is not available")
}
requireFileValidationError(t, err, errs.SubtypeFailedPrecondition)
})
t.Run("stdin read failure", func(t *testing.T) {
readErr := errors.New("pipe closed")
_, err := BuildFormdata(fio, "file", "", true, &failingReader{err: readErr}, nil)
if err == nil {
t.Fatal("expected error for failing stdin reader")
}
requireFileValidationError(t, err, errs.SubtypeInvalidArgument)
if !errors.Is(err, readErr) {
t.Error("underlying read error not reachable via errors.Is; WithCause missing")
}
})
t.Run("stdin empty", func(t *testing.T) {
@@ -308,7 +259,6 @@ func TestBuildFormdata(t *testing.T) {
if !strings.Contains(err.Error(), "stdin is empty") {
t.Errorf("error = %q, want containing %q", err.Error(), "stdin is empty")
}
requireFileValidationError(t, err, errs.SubtypeInvalidArgument)
})
t.Run("file open success", func(t *testing.T) {
@@ -339,10 +289,6 @@ func TestBuildFormdata(t *testing.T) {
if !strings.Contains(err.Error(), "cannot open file:") {
t.Errorf("error = %q, want containing %q", err.Error(), "cannot open file:")
}
valErr := requireFileValidationError(t, err, errs.SubtypeInvalidArgument)
if valErr.Cause == nil {
t.Error("expected the os open error attached as Cause")
}
})
t.Run("dataJSON fields added", func(t *testing.T) {

View File

@@ -7,8 +7,8 @@ import (
"encoding/json"
"io"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
// ParseOptionalBody parses --data JSON for methods that accept a request body.
@@ -22,18 +22,14 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.F
}
resolved, err := ResolveInput(data, stdin, fileIO)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data: %s", err).
WithParam("--data").
WithCause(err)
return nil, output.ErrValidation("--data: %s", err)
}
if resolved == "" {
return nil, nil
}
var body interface{}
if err := json.Unmarshal([]byte(resolved), &body); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON format").
WithParam("--data").
WithCause(err)
return nil, output.ErrValidation("--data invalid JSON format")
}
return body, nil
}
@@ -45,18 +41,14 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.F
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin, fileIO)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", label, err).
WithParam(label).
WithCause(err)
return nil, output.ErrValidation("%s: %s", label, err)
}
if resolved == "" {
return map[string]any{}, nil
}
var result map[string]any
if err := json.Unmarshal([]byte(resolved), &result); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s invalid format, expected JSON object", label).
WithParam(label).
WithCause(err)
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
if result == nil {
// `null` unmarshals into a nil map without error; normalize it so the

Some files were not shown because too many files have changed in this diff Show More