Compare commits

..

1 Commits

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

View File

@@ -10,6 +10,8 @@ on:
permissions: permissions:
contents: read contents: read
actions: read actions: read
checks: write
pull-requests: write
jobs: jobs:
# ── Layer 1: Fast Gate ───────────────────────────────────────────── # ── Layer 1: Fast Gate ─────────────────────────────────────────────
@@ -78,47 +80,10 @@ jobs:
python-version: '3.x' python-version: '3.x'
- name: Fetch meta data - name: Fetch meta data
run: python3 scripts/fetch_meta.py 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 - 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) - name: Run errs/ lint guards (lintcheck)
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" .. run: go run -C lint . ..
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
coverage: coverage:
needs: fast-gate 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/') 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 go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6 uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with: with:
files: coverage.txt files: coverage.txt
@@ -220,7 +184,7 @@ jobs:
# ── Layer 3: E2E Gate ────────────────────────────────────────────── # ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run: e2e-dry-run:
needs: [unit-test, lint, deterministic-gate] needs: [unit-test, lint]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
@@ -241,12 +205,9 @@ jobs:
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression' run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live: 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 }} if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
checks: write
env: env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }} TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }} TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
@@ -293,9 +254,6 @@ jobs:
# ── Layer 4: Security & Compliance (parallel with L2-L3) ────────── # ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
security: security:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps: steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with: with:
@@ -333,7 +291,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ───── # ── Results Gate (single required check for branch protection) ─────
results: results:
if: ${{ always() }} 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 runs-on: ubuntu-latest
steps: steps:
- name: Evaluate results - name: Evaluate results
@@ -345,7 +303,6 @@ jobs:
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.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 | 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 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.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 echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
@@ -361,7 +318,6 @@ jobs:
"${{ needs.fast-gate.result }}" \ "${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \ "${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \ "${{ needs.lint.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \ "${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \ "${{ needs.deadcode.result }}" \
"${{ needs.e2e-dry-run.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 });

View File

@@ -29,11 +29,11 @@ linters:
- unused # checks for unused constants, variables, functions and types - unused # checks for unused constants, variables, functions and types
- depguard # blocks forbidden package imports - depguard # blocks forbidden package imports
- forbidigo # forbids specific function calls - 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: # To enable later after fixing existing issues:
# - errcheck # checks for unchecked errors # - errcheck # checks for unchecked errors
# - errname # checks that error types are named XxxError # - errname # checks that error types are named XxxError
# - errorlint # checks error wrapping best practices
# - gosec # security-oriented linter # - gosec # security-oriented linter
# - misspell # finds commonly misspelled English words # - misspell # finds commonly misspelled English words
# - staticcheck # comprehensive static analysis # - staticcheck # comprehensive static analysis
@@ -49,16 +49,9 @@ linters:
- gocritic - gocritic
- depguard - depguard
- forbidigo - forbidigo
- errorlint # tests legitimately do identity (==) and concrete type-assert checks # Paths that run forbidigo. Add an entry when a path joins one of
# forbidigo runs repo-wide (minus the boundaries below) so errs-no-bare-wrap # the rules below.
# has no gap. The framework bans (os/vfs, raw HTTP, fmt.Print, filepath,
# log) stay scoped to shortcuts/ + internal/ + config/auth/service via the
# next rule; elsewhere only errs-no-bare-wrap fires.
- path-except: (shortcuts/|internal/|cmd/|events/)
linters:
- forbidigo
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/) - 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: linters:
- forbidigo - forbidigo
- path: internal/vfs/ - path: internal/vfs/
@@ -72,26 +65,31 @@ linters:
- path: shortcuts/.*/internal/gen/ - path: shortcuts/.*/internal/gen/
linters: linters:
- forbidigo - 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 # shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer. # for the client / credential layer.
- path-except: shortcuts/ - path-except: shortcuts/
text: shortcuts-no-raw-http text: shortcuts-no-raw-http
linters: linters:
- forbidigo - forbidigo
# errs-no-bare-wrap enforced across every command/wire boundary by # errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# structural prefix, so any future business domain or command is covered # Add a path when its migration is complete.
# without editing an allowlist. Genuine intermediate wraps inside these - 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/)
# paths use //nolint:forbidigo with a reason. text: errs-typed-only
- path-except: (cmd/|shortcuts/|events/) 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 text: errs-no-bare-wrap
linters: linters:
- forbidigo - 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: settings:
depguard: depguard:
@@ -110,6 +108,22 @@ linters:
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation. Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo: forbidigo:
forbid: 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 ── # ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b - pattern: (fmt\.Errorf|errors\.New)\b
msg: >- msg: >-

View File

@@ -2,67 +2,6 @@
All notable changes to this project will be documented in this file. 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 ## [v1.0.54] - 2026-06-15
### Features ### Features
@@ -1236,9 +1175,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese). - Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases. - 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.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.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52 [v1.0.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 MODULE := github.com/larksuite/cli
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
DATE := $(shell date +%Y-%m-%d) 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) LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local PREFIX ?= /usr/local
@@ -22,7 +15,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH)) TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race) 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 all: test
@@ -46,12 +39,6 @@ fmt-check:
exit 1; \ exit 1; \
fi 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. # ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta unit-test: fetch_meta
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \ go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
@@ -66,30 +53,7 @@ examples-build:
integration-test: build integration-test: build
go test -v -count=1 ./tests/... go test -v -count=1 ./tests/...
test: vet fmt-check script-test unit-test examples-build integration-test test: vet fmt-check 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)
install: build install: build
install -d $(PREFIX)/bin install -d $(PREFIX)/bin

View File

@@ -10,7 +10,6 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "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. // stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
if opts.Params == "-" && opts.Data == "-" { if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
"--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 (-)"},
)
} }
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO) 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 return client.RawApiRequest{}, nil, err
} }
if _, ok := dataFields.(map[string]any); !ok { if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
"--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")
} }
} }
@@ -206,13 +196,7 @@ func apiRun(opts *APIOptions) error {
} }
if opts.PageAll && opts.Output != "" { if opts.PageAll && opts.Output != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, return output.ErrValidation("--output and --page-all are mutually exclusive")
"--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"},
)
} }
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil { if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err return err
@@ -249,7 +233,7 @@ func apiRun(opts *APIOptions) error {
} }
if opts.PageAll { 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}) 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 // pass on *output.ExitError values. Typed *errs.* errors that flow
// through here keep their canonical message / hint from BuildAPIError; // through here keep their canonical message / hint from BuildAPIError;
// MarkRaw is a no-op on those (it only flips a flag on *ExitError). // 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{ err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output, OutputPath: opts.Output,
@@ -279,7 +263,7 @@ func apiRun(opts *APIOptions) error {
// MarkRaw: see comment above on the DoAPI path. Skips legacy // MarkRaw: see comment above on the DoAPI path. Skips legacy
// *ExitError enrichment; typed errors flow through unchanged. // *ExitError enrichment; typed errors flow through unchanged.
if err != nil { if err != nil {
return errs.MarkRaw(err) return output.MarkRaw(err)
} }
return nil 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) 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 == "" { if pagOpts.Identity == "" {
pagOpts.Identity = request.As pagOpts.Identity = request.As
} }
// When jq is set, always aggregate all pages then filter. // When jq is set, always aggregate all pages then filter.
if jqExpr != "" { if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts) if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
if err != nil { return output.MarkRaw(err)
return errs.MarkRaw(err)
} }
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil { return 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,
})
} }
switch format { switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV: case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format) pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error { result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items) pf.FormatPage(items)
return nil
}, pagOpts) }, pagOpts)
if err != nil { if err != nil {
return errs.MarkRaw(err) return output.MarkRaw(err)
} }
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil { 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 { if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format) 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{ output.FormatValue(out, result, output.FormatJSON)
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
} }
return nil return nil
default: default:
result, err := ac.PaginateAll(ctx, request, pagOpts) result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil { if err != nil {
return errs.MarkRaw(err) return output.MarkRaw(err)
} }
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil { if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON) output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr) return output.MarkRaw(apiErr)
} }
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{ output.FormatValue(out, result, format)
CommandPath: commandPath, return nil
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
} }
} }

View File

@@ -4,8 +4,6 @@
package api package api
import ( import (
"context"
"encoding/json"
"errors" "errors"
"os" "os"
"sort" "sort"
@@ -13,7 +11,6 @@ import (
"testing" "testing"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/httpmock"
@@ -104,19 +101,8 @@ func TestApiCmd_BotMode(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
var got map[string]interface{} if !strings.Contains(stdout.String(), "success") {
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { t.Error("expected 'success' in output")
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"])
} }
} }
@@ -342,16 +328,8 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
t.Error("expected 'falling back to json' in stderr") t.Error("expected 'falling back to json' in stderr")
} }
// Should output JSON result to stdout // Should output JSON result to stdout
var got map[string]interface{} if !strings.Contains(stdout.String(), "u123") {
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { t.Error("expected user_id in JSON output")
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())
} }
} }
@@ -364,7 +342,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_xxx/announcement", URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
Body: map[string]interface{}{ 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") t.Fatal("expected an error for non-zero code")
} }
// Should still output the response body so user can see the error details // 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()) 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()) 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) { 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) { func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
name string name string

View File

@@ -33,9 +33,12 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
if got := output.ExitCodeOf(err); got != 1 { if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got) t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
} }
var bare *output.BareError var bare *output.ExitError
if !errors.As(err, &bare) { 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 { if stderr.Len() != 0 {

View File

@@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth" larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "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 // keep the same contract here. We still want the hint to be
// workspace-aware, so we pull the message+hint out of // workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it. // NotConfiguredError() instead of hard-coding it.
var cfgErr *errs.ConfigError var cfgErr *core.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) { if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message) fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" { if cfgErr.Hint != "" {

View File

@@ -260,15 +260,6 @@ func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
} }
} }
func TestCollectScopesForDomains_SlidesDoesNotAdvertiseScreenshotScope(t *testing.T) {
scopes := collectScopesForDomains([]string{"slides"}, "user", "")
for _, scope := range scopes {
if scope == "slides:presentation:screenshot" {
t.Fatalf("slides domain scopes must not advertise allowlist-gated screenshot scope: %#v", scopes)
}
}
}
func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) { func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) {
domains := getDomainMetadata("zh") domains := getDomainMetadata("zh")
nameSet := make(map[string]bool) nameSet := make(map[string]bool)
@@ -887,7 +878,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
// contract that when --json is set and pollDeviceToken returns OK=false, // contract that when --json is set and pollDeviceToken returns OK=false,
// stdout carries the structured authorization_failed event and stderr is // stdout carries the structured authorization_failed event and stderr is
// NOT polluted with a typed envelope. The returned error is a bare // 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. // without emitting a second envelope on top of the JSON event.
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) { func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
keyring.MockInit() keyring.MockInit()
@@ -954,13 +945,16 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr) t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
} }
// Returned error must be the bare *output.BareError signal (no envelope). // Returned error must be the bare *output.ExitError signal (no envelope).
var bareErr *output.BareError var exitErr *output.ExitError
if !errors.As(err, &bareErr) { if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err) t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
} }
if bareErr.Code != output.ExitAuth { if exitErr.Code != output.ExitAuth {
t.Fatalf("BareError.Code = %d, want %d", bareErr.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" "github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update" cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events" _ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
@@ -34,13 +33,9 @@ import (
type BuildOption func(*buildConfig) type BuildOption func(*buildConfig)
type buildConfig struct { type buildConfig struct {
streams *cmdutil.IOStreams streams *cmdutil.IOStreams
keychain keychain.KeychainAccess keychain keychain.KeychainAccess
globals GlobalOptions globals GlobalOptions
skipPlugins bool
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
} }
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers. // 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 // Build constructs the full command tree. It also installs registered
// plugins and emits the Startup lifecycle event during assembly -- // plugins and emits the Startup lifecycle event during assembly --
// so Plugin.On(Startup) handlers run even if the returned command is // 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(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f)) rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f)) rootCmd.AddCommand(skill.NewCmdSkill(f))
if !cfg.skipService { service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
if cfg.serviceCatalog != nil {
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
} else {
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
}
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f) shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
installUnknownSubcommandGuard(rootCmd) installUnknownSubcommandGuard(rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode { if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
pruneForStrictMode(rootCmd, mode) pruneForStrictMode(rootCmd, mode)
} }
if cfg.skipPlugins {
recordInventory(nil)
return f, rootCmd, nil
}
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut) installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil { if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr) 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 package completion
import ( import (
"github.com/larksuite/cli/errs" "fmt"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -31,9 +32,7 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
case "powershell": case "powershell":
return root.GenPowerShellCompletionWithDesc(out) return root.GenPowerShellCompletionWithDesc(out)
default: default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, return fmt.Errorf("unsupported shell: %s", args[0])
"unsupported shell: %s", args[0]).
WithHint("supported shells: bash, zsh, fish, powershell")
} }
}, },
} }

View File

@@ -212,7 +212,10 @@ func finalizeSource(opts *BindOptions) (string, error) {
if opts.IsTUI && !opts.langExplicit { if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection() lang, err := promptLangSelection()
if err != nil { 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.Lang = string(lang)
opts.UILang = lang opts.UILang = lang

View File

@@ -20,29 +20,35 @@ import (
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
) )
// wantErrDetail is the normalized comparison shape for a typed error's wire // assertExitError checks the full structured error in one assertion. It
// fields: Type is the error's Category string ("validation", "config", ...), // accepts both *output.ExitError (used by output.ErrWithHint) and the
// alongside Message and Hint. // typed errors (ValidationError, ConfigError) — they normalize to the same
type wantErrDetail struct { // wantDetail fields. The wantDetail.Type is matched against the typed error's
Type string // Category string ("validation", "config", etc.).
Message string func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
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) {
t.Helper() t.Helper()
if err == nil { if err == nil {
t.Fatal("expected error, got 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 var ve *errs.ValidationError
if errors.As(err, &ve) { if errors.As(err, &ve) {
if got := output.ExitCodeOf(err); got != wantCode { if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", 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) { if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", 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 { if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", 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) { if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail) t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
} }
return 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 // assertEnvelope decodes stdout and checks it matches want exactly — every key
@@ -173,21 +179,15 @@ func TestConfigBindRun_InvalidLang(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang) t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
} }
var valErr *errs.ValidationError exitErr, ok := err.(*output.ExitError)
if !errors.As(err, &valErr) { if !ok {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
} }
if valErr.Subtype != errs.SubtypeInvalidArgument { if exitErr.Code != output.ExitValidation {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument) t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
} }
if valErr.Param != "--lang" { if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("param = %q, want %q", valErr.Param, "--lang") t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
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())
} }
}) })
} }
@@ -365,7 +365,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"}) err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{ assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation", Type: "validation",
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`, 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) f, _, _, _ := cmdutil.TestFactory(t, nil)
// TestFactory has IsTerminal=false by default // TestFactory has IsTerminal=false by default
err := configBindRun(&BindOptions{Factory: f, Source: ""}) err := configBindRun(&BindOptions{Factory: f, Source: ""})
assertExitError(t, err, output.ExitValidation, wantErrDetail{ assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation", Type: "validation",
Message: "cannot determine Agent source: no --source flag and no Agent environment detected", Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context", Hint: "pass --source openclaw|hermes|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) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{ assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation", Type: "validation",
Message: `--source "openclaw" does not match detected Agent environment (hermes)`, 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", 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) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{ assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation", Type: "validation",
Message: `--source "hermes" does not match detected Agent environment (openclaw)`, 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", 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) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env") envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, wantErrDetail{ assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config", Type: "config",
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory", Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
Hint: "verify Hermes is installed and configured at " + envPath, Hint: "verify Hermes is installed and configured at " + envPath,
@@ -584,7 +584,7 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json") configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
assertExitError(t, err, output.ExitAuth, wantErrDetail{ assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config", Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory", Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify OpenClaw is installed and configured", Hint: "verify OpenClaw is installed and configured",
@@ -731,7 +731,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{ assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation", Type: "validation",
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`, 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", 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) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json") configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
assertExitError(t, err, output.ExitAuth, wantErrDetail{ assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config", Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory", Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify lark-channel-bridge is installed and configured", Hint: "verify lark-channel-bridge is installed and configured",
@@ -770,7 +770,7 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{ assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config", Type: "config",
Message: "accounts.app.id missing in " + configPath, Message: "accounts.app.id missing in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential", 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) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{ assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config", Type: "config",
Message: "accounts.app.secret is empty in " + configPath, Message: "accounts.app.secret is empty in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential", 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") t.Fatal("expected error for unbound workspace")
} }
// Should be a structured ConfigError suggesting config bind, not config init. // Should be a structured ConfigError suggesting config bind, not config init.
var cfgErr *errs.ConfigError var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) { 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 // Config errors share ExitAuth (3); the workspace is detected but no
// binding exists yet, which is a config error. // binding exists yet, which is a config error.
if got := output.ExitCodeOf(err); got != output.ExitAuth { if cfgErr.Code != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, 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 if cfgErr.Type != "openclaw" {
// the message. t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
} }
if !strings.Contains(cfgErr.Message, "openclaw context detected") { if !strings.Contains(cfgErr.Message, "openclaw context detected") {
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message) 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 // iterates a map — ordering is non-deterministic. DeepEqual inline against
// each accepted variant so every ErrDetail field (Type, Code, Message, // each accepted variant so every ErrDetail field (Type, Code, Message,
// Hint, ConsoleURL, Detail, and any future addition) is still compared. // Hint, ConsoleURL, Detail, and any future addition) is still compared.
base := wantErrDetail{ base := output.ErrDetail{
Type: "validation", Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>", 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) { if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err) 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) { 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", t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
got, wantWorkFirst, wantPersonalFirst) got, wantWorkFirst, wantPersonalFirst)
@@ -1232,7 +1230,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"}) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{ assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation", Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`, Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only_one", Hint: "available app IDs:\n cli_only_one",
@@ -1252,7 +1250,7 @@ func TestConfigBindRun_InvalidIdentity(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"}) err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{ assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation", Type: "validation",
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`, 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) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env") envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, wantErrDetail{ assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config", Type: "config",
Message: "FEISHU_APP_ID not found in " + envPath, Message: "FEISHU_APP_ID not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials", Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1558,7 +1556,7 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env") envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, wantErrDetail{ assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config", Type: "config",
Message: "FEISHU_APP_SECRET not found in " + envPath, Message: "FEISHU_APP_SECRET not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials", Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1584,7 +1582,7 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{ assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config", Type: "config",
Message: "openclaw.json missing channels.feishu section", Message: "openclaw.json missing channels.feishu section",
Hint: "configure Feishu in OpenClaw first", Hint: "configure Feishu in OpenClaw first",
@@ -1612,7 +1610,7 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
openclawPath := filepath.Join(openclawDir, "openclaw.json") openclawPath := filepath.Join(openclawDir, "openclaw.json")
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{ assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config", Type: "config",
Message: "appSecret is empty for app cli_no_secret in " + openclawPath, Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
Hint: "configure channels.feishu.appSecret in openclaw.json", Hint: "configure channels.feishu.appSecret in openclaw.json",
@@ -1674,7 +1672,7 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{ assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config", Type: "config",
Message: "no Feishu app configured in openclaw.json", Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId 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) { func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t)) _, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, wantErrDetail{ assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config", Type: "config",
Message: "no Feishu app configured in openclaw.json", Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId 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. // even before it has a bespoke error message.
b := &fakeBinder{name: "hermes", path: "/tmp/.env"} b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t)) _, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, wantErrDetail{ assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config", Type: "config",
Message: "hermes: no app configured", Message: "hermes: no app configured",
}) })
@@ -100,7 +100,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
{AppID: "cli_home", Label: "home"}, {AppID: "cli_home", Label: "home"},
} }
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t)) _, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{ assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation", Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`, Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)", 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"}, {AppID: "cli_home", Label: "home"},
} }
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t)) _, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{ assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation", Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>", Message: "multiple accounts in openclaw.json; pass --app-id <id>",
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)", 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"} b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{{AppID: "cli_only"}} candidates := []Candidate{{AppID: "cli_only"}}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t)) _, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{ assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation", Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`, Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only", Hint: "available app IDs:\n cli_only",

View File

@@ -12,7 +12,6 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential" extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
@@ -93,16 +92,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Fatal("expected error") t.Fatal("expected error")
} }
var cfgErr *errs.ConfigError var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) { 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. // Config errors share ExitAuth (3), not ExitValidation.
if got := output.ExitCodeOf(err); got != output.ExitAuth { if cfgErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, 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" { if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr) t.Fatalf("detail = %+v, want config/not configured", cfgErr)
} }
} }
@@ -234,21 +233,15 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang) t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
} }
var valErr *errs.ValidationError exitErr, ok := err.(*output.ExitError)
if !errors.As(err, &valErr) { if !ok {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
} }
if valErr.Subtype != errs.SubtypeInvalidArgument { if exitErr.Code != output.ExitValidation {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument) t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
} }
if valErr.Param != "--lang" { if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("param = %q, want %q", valErr.Param, "--lang") t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
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())
} }
}) })
} }
@@ -392,38 +385,8 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
if err == nil { if err == nil {
t.Fatal("expected conflict error") t.Fatal("expected conflict error")
} }
// A name/appId conflict is user input — a typed validation error naming the if !strings.Contains(err.Error(), "conflicts with existing appId") {
// offending flag, not a system storage failure. t.Fatalf("error = %v, want conflict with existing appId", err)
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")
} }
} }

View File

@@ -6,11 +6,13 @@ package config
import ( import (
"bufio" "bufio"
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"strings" "strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
@@ -125,9 +127,12 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
if ws.IsLocal() { if ws.IsLocal() {
return nil return nil
} }
return errs.NewConfigError(errs.SubtypeNotConfigured, return &core.ConfigError{
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()). Code: 2,
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.") 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. // 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))) 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. // saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends. // If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed. // 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) multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
} else { } else {
if findAppIndexByAppID(multi, profileName) >= 0 { if findAppIndexByAppID(multi, profileName) >= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
"profile name %q conflicts with existing appId", profileName).
WithParam("--name")
} }
// Append new profile // Append new profile
multi.Apps = append(multi.Apps, core.AppConfig{ 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 // wrapUpdateExistingProfileErr classifies the error returned by
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError // updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
// for blank-input) pass through unchanged so their exit code semantics // for blank-input) pass through unchanged so their exit code semantics
// survive; everything else (filesystem, keychain, etc.) is wrapped as // survive; legacy *output.ExitError also passes through; everything else
// InternalError. // (filesystem, keychain, etc.) is wrapped as InternalError.
func wrapUpdateExistingProfileErr(err error) error { func wrapUpdateExistingProfileErr(err error) error {
if err == nil { if err == nil {
return nil return nil
@@ -269,6 +258,10 @@ func wrapUpdateExistingProfileErr(err error) error {
if errs.IsTyped(err) { if errs.IsTyped(err) {
return 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) 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) return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
} }
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil { 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())) output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts) printLangPreferenceConfirmation(opts)
@@ -360,7 +353,10 @@ func configInitRun(opts *ConfigInitOptions) error {
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() { if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
lang, err := promptLangSelection() lang, err := promptLangSelection()
if err != nil { 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.Lang = string(lang)
opts.UILang = lang opts.UILang = lang
@@ -383,7 +379,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) 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 { 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) printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) 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) 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 { 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 != "" { } else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only // 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) return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
} }
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil { 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())) output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts) printLangPreferenceConfirmation(opts)

View File

@@ -8,7 +8,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core"
) )
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) { func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
@@ -26,15 +26,12 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected refusal in OpenClaw context, got nil") t.Fatal("expected refusal in OpenClaw context, got nil")
} }
var cfgErr *errs.ConfigError var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) { 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 { if cfgErr.Type != "openclaw" {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype) t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
}
if !strings.Contains(cfgErr.Message, "openclaw") {
t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message)
} }
if !strings.Contains(cfgErr.Hint, "config bind --help") { if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint) 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 { if err == nil {
t.Fatal("expected refusal in Hermes context, got nil") t.Fatal("expected refusal in Hermes context, got nil")
} }
var cfgErr *errs.ConfigError var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) { 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 { if cfgErr.Type != "hermes" {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype) t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
}
if !strings.Contains(cfgErr.Message, "hermes") {
t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message)
} }
} }

View File

@@ -4,14 +4,10 @@
package config package config
import ( import (
"errors"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
) )
type initMsg struct { type initMsg struct {
@@ -101,12 +97,3 @@ func promptLangSelection() (i18n.Lang, error) {
} }
return lang, nil 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 // wrapUpdateExistingProfileErr is the caller-side classifier for the error
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error // returned by updateExistingProfileWithoutSecret. It must preserve typed-error
// exit semantics: a typed ValidationError must keep ExitValidation rather than // exit semantics (regression: typed ValidationError was being downgraded to
// being downgraded to InternalError. // InternalError by the legacy *output.ExitError-only passthrough).
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) { func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
if got := wrapUpdateExistingProfileErr(nil); got != nil { 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) { func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
in := fmt.Errorf("disk full") in := fmt.Errorf("disk full")
got := wrapUpdateExistingProfileErr(in) got := wrapUpdateExistingProfileErr(in)

View File

@@ -14,7 +14,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
@@ -95,7 +94,7 @@ func doctorRun(opts *DoctorOptions) error {
// underlying problem is still visible. // underlying problem is still visible.
msg, hint := err.Error(), "" msg, hint := err.Error(), ""
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
var cfgErr *errs.ConfigError var cfgErr *core.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) { if errors.As(core.NotConfiguredError(), &cfgErr) {
msg, hint = cfgErr.Message, cfgErr.Hint msg, hint = cfgErr.Message, cfgErr.Hint
} }
@@ -109,7 +108,7 @@ func doctorRun(opts *DoctorOptions) error {
cfg, err := f.Config() cfg, err := f.Config()
if err != nil { if err != nil {
hint := "" hint := ""
var cfgErr *errs.ConfigError var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) { if errors.As(err, &cfgErr) {
hint = cfgErr.Hint hint = cfgErr.Hint
} }

View File

@@ -15,6 +15,7 @@ import (
internalauth "github.com/larksuite/cli/internal/auth" internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts" "github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common" shortcutcommon "github.com/larksuite/cli/shortcuts/common"
@@ -48,6 +49,32 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
authErr.Hint += "\n" + scopeHint 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 // resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
// current command for the resolved identity, checking shortcuts first and then // current command for the resolved identity, checking shortcuts first and then
// service methods from local registry metadata. // service methods from local registry metadata.

View File

@@ -8,7 +8,7 @@ import (
"regexp" "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"'<>]+`) 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. // describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.

View File

@@ -4,117 +4,21 @@
package event package event
import ( import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"strings"
"github.com/larksuite/cli/internal/core" "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 // consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>. func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
// 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
}
host := core.ResolveEndpoints(brand).Open 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. // consoleEventSubscriptionURL points at the app's event subscription console page.
func consoleLandingURL(brand core.LarkBrand, appID string) string { func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID) return fmt.Sprintf("%s/app/%s/event", host, 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}
} }

View File

@@ -4,109 +4,33 @@
package event package event
import ( import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"io"
"strings"
"testing" "testing"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
) )
func decodeAddons(t *testing.T, encoded string) ManifestAddons { func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
t.Helper() got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
gz, err := base64.RawURLEncoding.DecodeString(encoded) "im:message:readonly",
if err != nil { "im:message.group_at_msg",
t.Fatalf("base64url decode: %v", err) })
} want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
zr, err := gzip.NewReader(bytes.NewReader(gz)) if got != want {
if err != nil { t.Errorf("url\n got: %s\nwant: %s", got, want)
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 TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) { func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}}) got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
if err != nil { want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
t.Fatalf("url: %v", err) if got != want {
} t.Errorf("url\n got: %s\nwant: %s", got, want)
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 TestMissingScopeAddons_ByIdentity(t *testing.T) { func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
bot := missingScopeAddons(core.AsBot, []string{"im:message"}) got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 { if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes) t.Errorf("unexpected url: %s", got)
}
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)
}
} }
} }

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") 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{ pf := &preflightCtx{
factory: f, factory: f,
appID: cfg.AppID, appID: cfg.AppID,
brand: cfg.Brand, brand: cfg.Brand,
eventKey: eventKey, eventKey: eventKey,
identity: identity, identity: identity,
keyDef: keyDef, keyDef: keyDef,
appVer: appVer, appVer: appVer,
subscribedCallbacks: subscribedCallbacks,
} }
if err := preflightEventTypes(pf); err != nil { if err := preflightEventTypes(pf); err != nil {
return err return err
@@ -243,9 +229,6 @@ type preflightCtx struct {
identity core.Identity identity core.Identity
keyDef *eventlib.KeyDefinition keyDef *eventlib.KeyDefinition
appVer *appmeta.AppVersion 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). // 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, ", ")). pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)). WithIdentity(string(pf.identity)).
WithMissingScopes(missing...). 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. // scopeRemediationHint returns an identity-appropriate fix for missing scopes.
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
// the tenant token carries them. User: the scan link only updates the app
// manifest — the user's own token still lacks the scopes until it is
// re-authorized — so direct the user to re-login instead.
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
if identity.IsBot() { if identity.IsBot() {
return fmt.Sprintf("grant these scopes by scanning: %s", return fmt.Sprintf(
addonsHintURL(brand, appID, missingScopeAddons(identity, missing))) "grant these scopes and publish a new app version at: %s",
consoleScopeGrantURL(brand, appID, missing),
)
} }
return fmt.Sprintf( 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.", "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 // preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
// in the app's console 底账 — published app_versions for event subscriptions,
// application/get subscribed_callbacks for callback subscriptions.
func preflightEventTypes(pf *preflightCtx) error { func preflightEventTypes(pf *preflightCtx) error {
if len(pf.keyDef.RequiredConsoleEvents) == 0 { if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
return nil return nil
} }
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
var subscribed []string for _, t := range pf.appVer.EventTypes {
noun := "event types" subscribed[t] = true
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
} }
var missing []string var missing []string
for _, t := range pf.keyDef.RequiredConsoleEvents { for _, t := range pf.keyDef.RequiredConsoleEvents {
if !have[t] { if !subscribed[t] {
missing = append(missing, t) missing = append(missing, t)
} }
} }
if len(missing) == 0 { if len(missing) == 0 {
return nil return nil
} }
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
return errs.NewValidationError(errs.SubtypeFailedPrecondition, return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"EventKey %s requires %s not subscribed in console: %s", "EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, noun, strings.Join(missing, ", ")). pf.keyDef.Key, strings.Join(missing, ", ")).
WithHint("subscribe these %s by scanning: %s", noun, url) 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). // 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. // Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
var ( var (
errInvalidParamFormat = errors.New("invalid --param format") //nolint:forbidigo // sentinel, typed at call sites errInvalidParamFormat = errors.New("invalid --param format")
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") //nolint:forbidigo // sentinel, typed at call sites errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
errOutputDirUnsafe = errors.New("unsafe --output-dir") //nolint:forbidigo // sentinel, typed at call sites errOutputDirUnsafe = errors.New("unsafe --output-dir")
) )
func parseParams(raw []string) (map[string]string, error) { func parseParams(raw []string) (map[string]string, error) {

View File

@@ -270,15 +270,15 @@ func TestExitForOrphan(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("flag on + orphan → expected error, got 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 { if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
t.Errorf("exit code = %v, want ExitValidation", err) t.Errorf("exit code = %v, want ExitValidation", err)
} }
} }
func errorAs(err error, target interface{}) bool { func errorAs(err error, target interface{}) bool {
if e, ok := err.(*output.BareError); ok { if e, ok := err.(*output.ExitError); ok {
if t, ok := target.(**output.BareError); ok { if t, ok := target.(**output.ExitError); ok {
*t = e *t = e
return true return true
} }

View File

@@ -97,9 +97,9 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryValidation, errs.SubtypeFailedPrecondition) 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) { 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 hint := permErr.Hint
wantSubstrings := []string{ wantSubstrings := []string{
"grant these scopes by scanning: ", "https://open.feishu.cn/app/cli_x/auth?q=",
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=", "im:message.group_at_msg",
"token_type=tenant",
} }
for _, want := range wantSubstrings { for _, want := range wantSubstrings {
if !strings.Contains(hint, want) { 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) 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

@@ -19,12 +19,12 @@ func TestExitForOrphan_Orphan(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error when failOnOrphan=true and orphan present") t.Fatal("expected error when failOnOrphan=true and orphan present")
} }
var bareErr *output.BareError var exitErr *output.ExitError
if !errors.As(err, &bareErr) { if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.BareError, got %T", err) t.Fatalf("expected *output.ExitError, got %T", err)
} }
if bareErr.Code != output.ExitValidation { if exitErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", bareErr.Code, output.ExitValidation) t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
} }
} }

View File

@@ -5,10 +5,10 @@ package cmd
import ( import (
"errors" "errors"
"slices"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -40,65 +40,31 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c.Flags().Bool("dry-run", false, "") c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var verr *errs.ValidationError var exitErr *output.ExitError
if !errors.As(err, &verr) { if !errors.As(err, &exitErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err) t.Fatalf("expected *output.ExitError, got %T", err)
} }
if verr.Subtype != errs.SubtypeInvalidArgument { if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype) t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
} }
if code := output.ExitCodeOf(err); code != output.ExitValidation { if !strings.Contains(exitErr.Detail.Hint, "--range") {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
} }
// The offending flag is carried structurally on Params (replaces the detail, _ := exitErr.Detail.Detail.(map[string]any)
// legacy detail map) and named in the message. valid, _ := detail["valid_flags"].([]string)
if len(verr.Params) != 1 || verr.Params[0].Name != "--rang" { if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
t.Errorf("Params = %v, want one entry named --rang", verr.Params) t.Errorf("valid_flags should list find & range, got %v", valid)
}
if len(verr.Params) == 1 && verr.Params[0].Reason == "" {
t.Error("Params[0].Reason must explain the rejection")
}
if !strings.Contains(verr.Message, "--rang") {
t.Errorf("message should name the offending flag, got %q", verr.Message)
}
// The ranked candidate rides on the param as a machine-readable suggestion
// so an agent can retry without parsing prose.
if len(verr.Params) == 1 {
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--range" {
found = true
}
}
if !found {
t.Errorf("Params[0].Suggestions should include --range, got %v", verr.Params[0].Suggestions)
}
}
// The same candidate is also carried in the human-facing hint.
if !strings.Contains(verr.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", verr.Hint)
} }
} }
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) { func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"} c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find")) err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var verr *errs.ValidationError var exitErr *output.ExitError
if !errors.As(err, &verr) { if !errors.As(err, &exitErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err) t.Fatalf("expected *output.ExitError, got %T", err)
} }
// Non-unknown-flag errors stay generic: invalid_argument subtype, no if exitErr.Detail.Type != "flag_error" {
// structured param, generic --help hint (no "did you mean" suggestion). t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
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)
} }
} }

View File

@@ -9,12 +9,10 @@ import (
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil" "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 // 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. // untouched.
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) { func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
cfgDir := tmpHome(t) cfgDir := tmpHome(t)
@@ -129,27 +127,13 @@ max_risk: write
if err == nil { if err == nil {
t.Fatalf("+delete-doc RunE should return an error") t.Fatalf("+delete-doc RunE should return an error")
} }
var verr *errs.ValidationError var exitErr *output.ExitError
if !errors.As(err, &verr) { if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
} }
if verr.Subtype != errs.SubtypeFailedPrecondition { detail, ok := exitErr.Detail.Detail.(map[string]any)
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) if !ok || detail["reason_code"] != "command_denylisted" {
} t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
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)
} }
// im/+send must be denied (domain not in Allow). // im/+send must be denied (domain not in Allow).

View File

@@ -8,9 +8,9 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
internalplatform "github.com/larksuite/cli/internal/platform" internalplatform "github.com/larksuite/cli/internal/platform"
) )
@@ -34,8 +34,16 @@ import (
// lands directly on their RunE, which now carries the guard. // lands directly on their RunE, which now carries the guard.
// //
// makeErr is called for every guarded dispatch; it must return a fresh // makeErr is called for every guarded dispatch; it must return a fresh
// typed error each time. // *output.ExitError each time (the envelope writer mutates a few fields
func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) { // 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 // Two cobra subcommands are injected lazily at Execute() time and
// would otherwise slip past walkGuard. We pre-register both so // would otherwise slip past walkGuard. We pre-register both so
// walkGuard catches them. // walkGuard catches them.
@@ -72,65 +80,120 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
} }
// installPluginInstallErrorGuard surfaces a FailClosed plugin install // installPluginInstallErrorGuard surfaces a FailClosed plugin install
// failure as a typed validation error (failed_precondition) before any // failure as a structured plugin_install envelope before any command
// command runs. // 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) { func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
makeErr := func() error { makeErr := func() *output.ExitError {
var pi *internalplatform.PluginInstallError var pi *internalplatform.PluginInstallError
if errors.As(installErr, &pi) { if errors.As(installErr, &pi) {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", pi.Error()). return &output.ExitError{
WithHint("plugin %q failed to install (reason_code %s); fix or remove the plugin before running commands", pi.PluginName, pi.ReasonCode). Code: output.ExitValidation,
WithCause(installErr) 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) installFatalGuard(rootCmd, makeErr)
} }
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration // installPluginConflictGuard surfaces a Plugin.Restrict() configuration
// error (single plugin invalid Rule or multiple plugins each contributing // 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 // - "plugin_install" with reason_code "invalid_rule" - single bad rule
// - "multiple_restrict_plugins" - multiple Restrict plugins conflict // - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
// //
// Either way the CLI must NOT silently continue with a broken policy. // 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) { func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
makeErr := func() error { makeErr := func() *output.ExitError {
envelopeType := "plugin_install"
reasonCode := internalplatform.ReasonInvalidRule reasonCode := internalplatform.ReasonInvalidRule
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) { if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
envelopeType = "plugin_conflict"
reasonCode = internalplatform.ReasonMultipleRestricts reasonCode = internalplatform.ReasonMultipleRestricts
} }
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()). return &output.ExitError{
WithHint("plugin policy configuration is broken (reason_code %s); fix the plugin's Restrict rule or remove the conflicting plugin", reasonCode). Code: output.ExitValidation,
WithCause(err) Detail: &output.ErrDetail{
Type: envelopeType,
Message: err.Error(),
Detail: map[string]any{
"reason_code": reasonCode,
},
},
Err: err,
}
} }
installFatalGuard(rootCmd, makeErr) installFatalGuard(rootCmd, makeErr)
} }
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler // installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
// failure as a typed validation error (failed_precondition). The hint's // failure as a plugin_lifecycle envelope. The reason_code splits
// reason code splits returned-error vs panic so consumers (audit / // returned-error vs panic so consumers (audit / on-call) can tell the
// on-call) can tell the two failure modes apart. // 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) { func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
makeErr := func() error { makeErr := func() *output.ExitError {
reasonCode := "lifecycle_failed" reasonCode := "lifecycle_failed"
hookName := "" detail := map[string]any{
"reason_code": reasonCode,
}
var le *hook.LifecycleError var le *hook.LifecycleError
if errors.As(err, &le) { if errors.As(err, &le) {
if le.Panic { if le.Panic {
reasonCode = "lifecycle_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()). return &output.ExitError{
WithCause(err) Code: output.ExitValidation,
if hookName != "" { Detail: &output.ErrDetail{
return typed.WithHint("plugin startup hook %q failed (reason_code %s); fix or remove the plugin before running commands", hookName, reasonCode) 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) 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 // This way the very first non-nil step in cobra's chain is always our
// guard, regardless of which leaf the user invoked. // 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 { if cmd == nil {
return return
} }

View File

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

View File

@@ -8,13 +8,11 @@ import (
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
@@ -158,23 +156,19 @@ func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
} }
err = leaf.RunE(leaf, nil) err = leaf.RunE(leaf, nil)
var verr *errs.ValidationError var exitErr *output.ExitError
if !errors.As(err, &verr) { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
} }
if verr.Subtype != errs.SubtypeFailedPrecondition { if exitErr.Detail.Type != "hook" {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
} }
if code := output.ExitCodeOf(err); code != output.ExitValidation { detail := exitErr.Detail.Detail.(map[string]any)
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) 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 if detail["hook_name"] != "policy-plugin.policy" {
// message so a caller can identify which plugin hook rejected the call. t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
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)
} }
// errors.As must still reach the original AbortError so consumers // 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") t.Fatalf("no runnable leaf in command tree")
} }
err := leaf.RunE(leaf, nil) err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError var exitErr *output.ExitError
if !errors.As(err, &verr) { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
} }
if verr.Subtype != errs.SubtypeFailedPrecondition { if exitErr.Detail.Type != "plugin_conflict" {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
} }
if code := output.ExitCodeOf(err); code != output.ExitValidation { if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
}
// 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)
} }
} }
@@ -458,20 +447,15 @@ func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
t.Fatalf("no runnable leaf in command tree") t.Fatalf("no runnable leaf in command tree")
} }
err := leaf.RunE(leaf, nil) err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError var exitErr *output.ExitError
if !errors.As(err, &verr) { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
} }
if verr.Subtype != errs.SubtypeFailedPrecondition { if exitErr.Detail.Type != "plugin_install" {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
} }
if code := output.ExitCodeOf(err); code != output.ExitValidation { if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) t.Errorf("reason_code = %v, want invalid_rule", rc)
}
// 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)
} }
} }
@@ -500,24 +484,19 @@ func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
leaf := findRunnableLeaf(root) leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil) err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError var exitErr *output.ExitError
if !errors.As(err, &verr) { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
} }
if verr.Subtype != errs.SubtypeFailedPrecondition { if exitErr.Detail.Type != "plugin_lifecycle" {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
} }
if code := output.ExitCodeOf(err); code != output.ExitValidation { d := exitErr.Detail.Detail.(map[string]any)
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) 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 if d["hook_name"] != "lc.start" {
// hook name are folded into the hint so audit / on-call can tell the t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
// 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)
} }
} }
@@ -541,20 +520,12 @@ func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
} }
leaf := findRunnableLeaf(root) leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil) err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError var exitErr *output.ExitError
if !errors.As(err, &verr) { if !errors.As(err, &exitErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err) t.Fatalf("expected *output.ExitError, got %T", err)
} }
if verr.Subtype != errs.SubtypeFailedPrecondition { if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) t.Errorf("reason_code = %v, want lifecycle_panic", rc)
}
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)
} }
} }
@@ -608,24 +579,19 @@ func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
}() }()
err = leaf.RunE(leaf, nil) err = leaf.RunE(leaf, nil)
var verr *errs.ValidationError var exitErr *output.ExitError
if !errors.As(err, &verr) { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
} }
if verr.Subtype != errs.SubtypeFailedPrecondition { if exitErr.Detail.Type != "hook" {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
} }
if code := output.ExitCodeOf(err); code != output.ExitValidation { d := exitErr.Detail.Detail.(map[string]any)
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) 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 if d["hook_name"] != "p.boom" {
// namespaced hook (p.boom) and describing the panic, so the process t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
// 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)
} }
} }
@@ -687,24 +653,19 @@ func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
}() }()
err = leaf.RunE(leaf, nil) err = leaf.RunE(leaf, nil)
var verr *errs.ValidationError var exitErr *output.ExitError
if !errors.As(err, &verr) { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
} }
if verr.Subtype != errs.SubtypeFailedPrecondition { if exitErr.Detail.Type != "hook" {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
} }
if code := output.ExitCodeOf(err); code != output.ExitValidation { d := exitErr.Detail.Detail.(map[string]any)
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) 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 if d["hook_name"] != "fac.bad-factory" {
// recovered into the same structured panic error, naming the t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
// 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)
} }
} }

View File

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

View File

@@ -11,7 +11,6 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n" "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") { if !strings.Contains(err.Error(), "failed to load config") {
t.Fatalf("error = %v, want failed to load config", err) 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: // TestProfileAddRun_Lang covers the unified --lang contract on profile add:
@@ -106,9 +95,9 @@ func TestProfileAddRun_Lang(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected validation error for --lang ZH, got nil") t.Fatal("expected validation error for --lang ZH, got nil")
} }
var valErr *errs.ValidationError exitErr, ok := err.(*output.ExitError)
if !errors.As(err, &valErr) || output.ExitCodeOf(err) != output.ExitValidation { if !ok || exitErr.Code != output.ExitValidation {
t.Fatalf("expected typed validation error with ExitValidation, got %T: %v", err, err) 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) { func assertInternalExitError(t *testing.T, err error, wantMsg string) {
t.Helper() t.Helper()
var internalErr *errs.InternalError var exitErr *output.ExitError
if !errors.As(err, &internalErr) { if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err) t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
} }
if internalErr.Subtype != errs.SubtypeStorage { if exitErr.Code != output.ExitInternal {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeStorage) t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
} }
if internalErr.Cause == nil { if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
t.Fatalf("cause = nil, want wrapped underlying error") t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
} }
if !strings.Contains(internalErr.Message, wantMsg) { if !strings.Contains(exitErr.Detail.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", internalErr.Message, wantMsg) t.Fatalf("message = %q, want contains %q", exitErr.Detail.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")
} }
} }

View File

@@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth" larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
@@ -41,12 +40,11 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
idx := multi.FindAppIndex(name) idx := multi.FindAppIndex(name)
if idx < 0 { 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 { if len(multi.Apps) == 1 {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile"). return output.ErrValidation("cannot remove the only profile")
WithHint("add another profile first: lark-cli profile add")
} }
app := &multi.Apps[idx] app := &multi.Apps[idx]
@@ -67,7 +65,7 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
} }
if err := core.SaveMultiAppConfig(multi); err != nil { if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
} }
// Best-effort credential cleanup after config commit // Best-effort credential cleanup after config commit

View File

@@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output" "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 { func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
if err := core.ValidateProfileName(newName); err != nil { 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() multi, err := core.LoadOrNotConfigured()
@@ -41,7 +40,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
idx := multi.FindAppIndex(oldName) idx := multi.FindAppIndex(oldName)
if idx < 0 { 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 // 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 continue
} }
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName { if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", newName). return output.ErrValidation("profile %q already exists", newName)
WithHint("choose a different name")
} }
} }
@@ -68,7 +66,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
} }
if err := core.SaveMultiAppConfig(multi); err != nil { if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
} }
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName)) 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/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
@@ -41,15 +40,14 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
// Handle "-" for toggle-back // Handle "-" for toggle-back
if name == "-" { if name == "-" {
if multi.PreviousApp == "" { if multi.PreviousApp == "" {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "no previous profile to switch back to"). return output.ErrValidation("no previous profile to switch back to")
WithHint("switch to a profile by name first: lark-cli profile use <name>")
} }
name = multi.PreviousApp name = multi.PreviousApp
} }
app := multi.FindApp(name) app := multi.FindApp(name)
if app == nil { 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() targetName := app.ProfileName()
@@ -68,7 +66,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
multi.CurrentApp = targetName multi.CurrentApp = targetName
if err := core.SaveMultiAppConfig(multi); err != nil { if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
} }
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand)) 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/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
) )
// pruneForStrictMode removes commands incompatible with the active strict mode. // 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 // pick auth's instead of our denial. A leaf-level no-op makes
// cobra stop here and proceed to the wrapped RunE. // cobra stop here and proceed to the wrapped RunE.
// //
// strict-mode keeps its short Message + independent Hint and wraps // strict-mode keeps its short Message + independent Hint and
// the CommandDeniedError as the Cause by hand; BuildDenialError // composes the shared detail.* / wrapped-CommandDeniedError shape
// would override Message with the CommandDeniedError.Error() long // by hand; BuildDenialError would override Message with the
// form. // CommandDeniedError.Error() long form.
stubMessage := fmt.Sprintf( stubMessage := fmt.Sprintf(
"strict mode is %q, only %s-identity commands are available", "strict mode is %q, only %s-identity commands are available",
mode, mode.ForcedIdentity()) mode, mode.ForcedIdentity())
@@ -105,9 +105,20 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
}, },
RunE: func(c *cobra.Command, _ []string) error { RunE: func(c *cobra.Command, _ []string) error {
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial) cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage). // Legacy *output.ExitError producer: this literal predates the
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint). // typed error contract introduced by errs/. New denial sites MUST
WithCause(cd) // 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" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil" "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 // Pins the strict-mode envelope shape: structured detail.* / wrapped
// *errs.ValidationError (exit 2) carrying the short historical Message, // CommandDeniedError for external agents, AND the historical short
// a Hint that still surfaces the policy layer + reason code (the // Message + independent Hint for existing consumers.
// 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.
func TestStrictModeStub_StructuredEnvelope(t *testing.T) { func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
root := newTestTree() root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot) pruneForStrictMode(root, core.StrictModeBot)
@@ -266,33 +262,30 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
t.Fatalf("strict-mode stub RunE should return error") t.Fatalf("strict-mode stub RunE should return error")
} }
var verr *errs.ValidationError var ee *output.ExitError
if !errors.As(err, &verr) { if !errors.As(err, &ee) {
t.Fatalf("err is not *errs.ValidationError: %T", err) t.Fatalf("err is not *output.ExitError: %T", err)
} }
if verr.Subtype != errs.SubtypeFailedPrecondition { if ee.Detail == nil {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON")
} }
if code := output.ExitCodeOf(err); code != output.ExitValidation { if ee.Detail.Type != "command_denied" {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type)
} }
// Short historical Message is preserved verbatim. dm, ok := ee.Detail.Detail.(map[string]any)
if verr.Message != `strict mode is "bot", only bot-identity commands are available` { if !ok {
t.Errorf("Message = %q, want short historical form", verr.Message) t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail)
} }
// The denial layer + reason code remain user-readable in the hint, and if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode {
// the historical switch-policy guidance is still appended. t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode)
if !strings.Contains(verr.Hint, cmdpolicy.LayerStrictMode) {
t.Errorf("Hint = %q, want substring %q (policy layer)", verr.Hint, cmdpolicy.LayerStrictMode)
} }
if !strings.Contains(verr.Hint, "identity_not_supported") { if got, _ := dm["reason_code"].(string); got != "identity_not_supported" {
t.Errorf("Hint = %q, want substring identity_not_supported (reason code)", verr.Hint) 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") { if got, _ := dm["policy_source"].(string); got != "strict-mode" {
t.Errorf("Hint = %q, want historical switch-policy guidance", verr.Hint) t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got)
} }
// The structured denial taxonomy survives on the wrapped cause.
var cd *platform.CommandDeniedError var cd *platform.CommandDeniedError
if !errors.As(err, &cd) { if !errors.As(err, &cd) {
t.Fatalf("err does not unwrap to *platform.CommandDeniedError") 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" { if cd.ReasonCode != "identity_not_supported" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode) 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"`) { if !strings.Contains(cd.Reason, `strict mode is "bot"`) {
t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason) 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 // 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/errs"
"github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/extension/platform"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation" "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/hook"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest" "github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update" "github.com/larksuite/cli/internal/update"
@@ -212,37 +217,56 @@ func configureFlagCompletions(args []string) {
// and returns the process exit code. // and returns the process exit code.
// //
// Dispatch order: // Dispatch order:
// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError, // 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError)
// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError): // are promoted via errcompat to their typed errs/ counterparts, with the
// render via the typed envelope writer, which lifts extension fields // original preserved in the Cause chain.
// (missing_scopes, console_url, challenge_url, ...) to the top level. // 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are // *errs.SecurityPolicyError, *errs.AuthenticationError): render via the
// constructed typed at their origin (internal/auth, internal/core), so the // typed envelope writer, which lifts extension fields (missing_scopes,
// dispatcher no longer promotes any legacy shape here. // console_url, challenge_url, ...) to the top level. Routed by
// 2. PartialFailure / BareError signals: the result envelope is already on // errs.CategoryOf via ExitCodeOf.
// stdout; honor the exit code and write nothing to stderr. // 3. Legacy *output.ExitError: asExitError adapts it to the legacy
// 3. Residual cobra usage errors (missing required flag, unknown command, // envelope, written via WriteErrorEnvelope.
// argument validation): typed as an invalid_argument envelope (exit 2), // 4. Cobra errors (required flags, unknown commands, etc.): plain text.
// matching the explicit flag/subcommand guards. Flag parse errors are
// already typed upstream by the root FlagErrorFunc.
func handleRootError(f *cmdutil.Factory, err error) int { func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut 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 // 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 // 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 // concrete scope(s) to re-auth with. The hint is computed on the fly from
// local shortcut/service metadata — it never depends on server state. // local shortcut/service metadata — 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 // Staged dispatch: capture the typed exit code BEFORE attempting the
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire // envelope write. WriteTypedErrorEnvelope is best-effort on the wire
// (partial-write still returns true) so the exit code we read here is // (partial-write still returns true) so the exit code we read here is
// preserved even if stderr is torn — torn stderr must not downgrade // 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 // 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) typedExit := output.ExitCodeOf(err)
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) { if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
return typedExit return typedExit
@@ -255,63 +279,58 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return pfErr.Code return pfErr.Code
} }
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`): if exitErr := asExitError(err); exitErr != nil {
// stdout already carries the result; honor the requested exit code and if !exitErr.Raw {
// write nothing to stderr. // Raw errors (e.g. from `api` command via output.MarkRaw)
var bareErr *output.BareError // preserve the original API error detail; skip enrichment
if errors.As(err, &bareErr) { // which would clear it.
return bareErr.Code enrichMissingScopeError(f, exitErr)
} enrichPermissionError(f, exitErr)
// 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
} }
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 // 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 // Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
// with a typed *errs.ValidationError: a flag that belongs to a missing // predates the typed error contract introduced by errs/. New code MUST NOT
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name // add producers of this shape — unknown-subcommand signals should move to
// each fail structured (exit 2) instead of degrading to help + exit 0. // 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 { func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 { if len(args) == 0 {
// A bare group (e.g. `sheets`), or one carrying only group-valid flags // 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() return cmd.Help()
} }
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 { if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
verr := errs.NewValidationError(errs.SubtypeInvalidArgument, return &output.ExitError{
"unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()). Code: output.ExitValidation,
WithHint("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()) Detail: &output.ErrDetail{
for _, flag := range unknown { Type: "unknown_flag",
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "unknown flag before a subcommand"}) 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 // 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 // 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 { if len(misplaced) == 0 {
return cmd.Help() return cmd.Help()
} }
verr := errs.NewValidationError(errs.SubtypeInvalidArgument, return &output.ExitError{
"missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")). Code: output.ExitValidation,
WithHint("run `%s --help` to list subcommands and their flags", cmd.CommandPath()) Detail: &output.ErrDetail{
for _, flag := range misplaced { Type: "missing_subcommand",
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "flag belongs to a subcommand, not the group"}) 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] unknown := args[0]
available, deprecated := availableSubcommandNames(cmd) 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)", hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
strings.Join(suggestions, ", "), cmd.CommandPath()) strings.Join(suggestions, ", "), cmd.CommandPath())
} }
// Record the offending subcommand and its ranked candidates as a param with detail := map[string]any{
// machine-readable Suggestions so an agent can retry without parsing the "unknown": unknown,
// hint; the hint carries the same candidates as prose. "command_path": cmd.CommandPath(),
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg). "suggestions": suggestions,
WithParams(errs.InvalidParam{Name: unknown, Reason: "unknown subcommand", Suggestions: suggestions}). "available": available,
WithHint("%s", hint) }
// 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 // 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 // flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into a typed validation envelope: an // converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when // unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds // in detail (so agents recover even when the typo is semantic, e.g. --query vs
// nothing) and the offending flag in `params`. Other flag errors stay typed // --find, where edit distance alone finds nothing). Other flag errors stay
// but generic. // structured but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error { func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr) name, isUnknown := unknownFlagName(ferr)
if !isUnknown { if !isUnknown {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()). return &output.ExitError{
WithHint("run `%s --help` for valid flags", c.CommandPath()) 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) valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3) 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()) hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 { 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)", hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath()) strings.Join(suggestions, ", "), c.CommandPath())
} }
// The ranked candidates ride on the param as machine-readable Suggestions so return &output.ExitError{
// an agent can retry without parsing the hint; the hint carries the same Code: output.ExitValidation,
// candidates as prose. The full valid-flag list stays recoverable via --help. Detail: &output.ErrDetail{
return errs.NewValidationError(errs.SubtypeInvalidArgument, Type: "unknown_flag",
"unknown flag %q for %q", "--"+name, c.CommandPath()). Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}). Hint: hint,
WithHint("%s", 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 // 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" "context"
"encoding/json" "encoding/json"
"os" "os"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -26,12 +27,12 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// Canonical strict-mode envelope messages shared across fixtures. The // Canonical strict-mode envelope strings shared across fixtures
// switch-policy hint text is asserted by substring in // (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
// assertStrictModeDenialEnvelope.
const ( const (
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available` strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
strictModeUserMessage = `strict mode is "user", only user-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 // 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 return 0
} }
// typedErrorEnvelope mirrors the typed wire shape produced by // parseEnvelope parses stderr bytes into an ErrorEnvelope.
// WriteTypedErrorEnvelope: the inner error marshals an errs.Problem func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
// 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 {
t.Helper() t.Helper()
if stderr.Len() == 0 { if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty") t.Fatal("expected non-empty stderr, got empty")
} }
var raw map[string]any var env output.ErrorEnvelope
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
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil { 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 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 { func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper() t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"} 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 // 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 // stub error fires (not login.go's inline check, which is shadowed by
// pruning). The typed envelope is a failed_precondition validation // pruning).
// error (exit 2); the strict-mode layer + reason code are folded into assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
// the hint. OK: false,
if code != output.ExitValidation { Error: &output.ErrDetail{
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) Type: "command_denied",
} Message: strictModeBotMessage,
if stdout.Len() != 0 { Hint: strictModeHint,
t.Errorf("expected empty stdout, got:\n%s", stdout.String()) Detail: map[string]any{
} "path": "auth/login",
env := parseTypedEnvelope(t, stderr) "layer": "strict_mode",
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage) "policy_source": "strict-mode",
} "rule_name": "",
"reason_code": "identity_not_supported",
// assertStrictModeDenialEnvelope pins the shared strict-mode denial shape: "reason": strictModeBotMessage,
// 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)
}
} }
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) { 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", "im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
}) })
if code != output.ExitValidation { assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) OK: false,
} Error: &output.ErrDetail{
if stdout.Len() != 0 { Type: "command_denied",
t.Errorf("expected empty stdout, got:\n%s", stdout.String()) Message: strictModeBotMessage,
} Hint: strictModeHint,
env := parseTypedEnvelope(t, stderr) Detail: map[string]any{
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage) "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) { 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", "im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
}) })
if code != output.ExitValidation { assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) OK: false,
} Identity: "bot",
if stdout.Len() != 0 { Error: &output.ErrDetail{
t.Errorf("expected empty stdout, got:\n%s", stdout.String()) Type: "validation",
} Message: `strict mode is "user", only user-identity commands are available`,
env := parseTypedEnvelope(t, stderr) 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)",
assertCheckStrictModeEnvelope(t, env, strictModeUserMessage) },
})
} }
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) { 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", "im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
}) })
if code != output.ExitValidation { assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) OK: false,
} Identity: "user",
if stdout.Len() != 0 { Error: &output.ErrDetail{
t.Errorf("expected empty stdout, got:\n%s", stdout.String()) Type: "validation",
} Message: `strict mode is "bot", only bot-identity commands are available`,
env := parseTypedEnvelope(t, stderr) 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)",
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage) },
})
} }
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) { 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", "im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
}) })
if code != output.ExitValidation { assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) OK: false,
} Error: &output.ErrDetail{
if stdout.Len() != 0 { Type: "command_denied",
t.Errorf("expected empty stdout, got:\n%s", stdout.String()) Message: strictModeUserMessage,
} Hint: strictModeHint,
env := parseTypedEnvelope(t, stderr) Detail: map[string]any{
assertStrictModeDenialEnvelope(t, env, strictModeUserMessage) "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) { 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", "api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
}) })
if code != output.ExitValidation { assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) OK: false,
} Identity: "user",
if stdout.Len() != 0 { Error: &output.ErrDetail{
t.Errorf("expected empty stdout, got:\n%s", stdout.String()) Type: "validation",
} Message: `strict mode is "bot", only bot-identity commands are available`,
env := parseTypedEnvelope(t, stderr) 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)",
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage) },
})
} }
// --- shortcut command --- // --- 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", "im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
}) })
// shortcut: typed errs.APIError via the CallAPITyped → BuildAPIError path. // shortcut: typed error via DoAPIJSON path
if code != output.ExitAPI { assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
t.Errorf("exit code = %d, want %d (ExitAPI)", code, output.ExitAPI) OK: false,
} Identity: "bot",
if stdout.Len() != 0 { Error: &output.ErrDetail{
t.Errorf("expected empty stdout, got:\n%s", stdout.String()) Type: "api",
} Code: 230002,
if stderr.Len() == 0 { Message: "Bot/User can NOT be out of the chat.",
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.")
}
} }
// TestSetupNotices_ColdStart_NoNotice verifies that missing state // 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 // TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that
// *errs.SecurityPolicyError flows through the canonical typed envelope // *errs.SecurityPolicyError flows through the canonical typed envelope
// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype, // (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype,
@@ -266,11 +269,12 @@ func (f *failingWriter) Write(p []byte) (int, error) {
return len(p), nil return len(p), nil
} }
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins that a // TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
// backward-compat alias failing on a cobra-level required flag (which // backward-compat alias that fails on a cobra-level required flag (which
// short-circuits before RunE) routes through the structured envelope, so the // short-circuits before RunE) still routes through the structured envelope,
// deprecation notice OnInvoke records in PreRunE is carried on the wire instead // because OnInvoke records the deprecation in PreRunE and the legacy fallback
// of being dropped on a plain "Error:" line. // 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) { func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) }) t.Cleanup(func() { deprecation.SetPending(nil) })
@@ -282,9 +286,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
deprecation.SetPending(&deprecation.Notice{ deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets", Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
}) })
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed // The bare error shape cobra's ValidateRequiredFlags produces: neither typed
// errs.* error, so it reaches the deprecation fallback. // nor an *output.ExitError, so it reaches the legacy fallback.
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values")) handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String() out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") { 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") { if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out) 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 // TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
// baseline for auth/config errors: it pins the typed envelope and exit code the // deprecation pending, the legacy fallback stays a plain "Error:" line, so the
// dispatcher produces for the two source-of-truth shapes, which are constructed // fix does not reshape every unrecognized cobra error.
// typed at their origin in internal/auth and internal/core. func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
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) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) }) t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil) deprecation.SetPending(nil)
@@ -391,45 +311,9 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
errOut := &bytes.Buffer{} errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values")) handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
if !strings.HasPrefix(errOut.String(), "Error:") {
out := errOut.String() t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", 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))
} }
} }
@@ -453,32 +337,12 @@ func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
} }
} }
// TestHandleRootError_BareErrorExitCodeNoStderr pins the silent-exit // TestHandleRootError_TypedOuterShortCircuitsPromote pins that when a typed
// contract: a *output.BareError is honored for its exit code while stderr stays // *errs.AuthenticationError carries a legacy *NeedAuthorizationError in its
// empty (stdout already carries the result, so the dispatcher must not layer a // Cause chain, the dispatcher does NOT run PromoteAuthError — doing so
// second envelope on top). // would replace the producer's TokenExpired subtype + custom hint with the
func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) { // promoted shape's TokenMissing.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) {
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) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil) 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) 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 ( import (
"encoding/json" "encoding/json"
"errors"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
) )
@@ -211,45 +209,6 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
if !strings.Contains(err.Error(), "Unknown service") { if !strings.Contains(err.Error(), "Unknown service") {
t.Errorf("expected 'Unknown service' error, got: %v", err) 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, // 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/apicatalog"
"github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential" "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) { 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 // 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 // uses — RuntimeCatalog().Services() is the deterministic, sorted view of the
// loop rather than a flat WalkMethods(nil) drive precisely so a service with // merged metadata — so registration is catalog-sourced end to end. Kept as a
// no methods still gets its bare command (WalkMethods yields one ref per // per-service loop rather than a flat WalkMethods(nil) drive precisely so a
// method, so empty services would vanish). // service with no methods still gets its bare command (WalkMethods yields one
for _, svc := range catalog.Services() { // ref per method, so empty services would vanish).
for _, svc := range registry.RuntimeCatalog().Services() {
if svc.Name == "" || svc.ServicePath == "" { if svc.Name == "" || svc.ServicePath == "" {
continue continue
} }
@@ -88,12 +84,10 @@ func serviceShort(svc meta.Service) string {
func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command { func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command {
for _, c := range parent.Commands() { for _, c := range parent.Commands() {
if c.Name() == name { if c.Name() == name {
cmdmeta.SetSource(c, cmdmeta.SourceService, true)
return c return c
} }
} }
cmd := &cobra.Command{Use: name, Short: short} cmd := &cobra.Command{Use: name, Short: short}
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
parent.AddCommand(cmd) parent.AddCommand(cmd)
return cmd return cmd
} }
@@ -237,7 +231,6 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
return serviceMethodRun(opts) return serviceMethodRun(opts)
}, },
} }
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.") cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.")
if spec.acceptsBody { if spec.acceptsBody {
@@ -387,7 +380,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
checkErr := ac.CheckResponse checkErr := ac.CheckResponse
if opts.PageAll { 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) 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) 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 == "" { if pagOpts.Identity == "" {
pagOpts.Identity = request.As pagOpts.Identity = request.As
} }
// When jq is set, always aggregate all pages then filter. // When jq is set, always aggregate all pages then filter.
if jqExpr != "" { if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts) return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
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,
})
} }
switch format { switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV: case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format) pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error { result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items) pf.FormatPage(items)
return nil
}, pagOpts) }, pagOpts)
if err != nil { if err != nil {
return err return err
@@ -675,12 +643,7 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
} }
if !hasItems { if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format) 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{ output.FormatValue(out, result, output.FormatJSON)
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
} }
return nil return nil
default: default:
@@ -689,14 +652,9 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
return err return err
} }
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil { if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr return apiErr
} }
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{ output.FormatValue(out, result, format)
CommandPath: commandPath, return nil
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
} }
} }

View File

@@ -4,15 +4,10 @@
package service package service
import ( import (
"context"
"encoding/json"
"errors"
"os" "os"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/httpmock"
@@ -412,19 +407,8 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
if err := cmd.Execute(); err != nil { if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
var got map[string]interface{} if !strings.Contains(stdout.String(), "success") {
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { t.Errorf("expected 'success' in output, got:\n%s", stdout.String())
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"])
} }
} }
@@ -452,312 +436,8 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
if err := cmd.Execute(); err != nil { if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
var got map[string]interface{} if !strings.Contains(stdout.String(), `"id"`) {
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { t.Errorf("expected items in output, got:\n%s", stdout.String())
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)
} }
} }
@@ -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 ── // ── file upload ──
func imImageMethod() meta.Method { func imImageMethod() meta.Method {

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
@@ -335,88 +334,13 @@ func TestUpdateFetchError_Human(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected non-nil error, got nil") t.Fatal("expected non-nil error, got nil")
} }
var netErr *errs.NetworkError var exitErr *output.ExitError
if !errors.As(err, &netErr) { if !errors.As(err, &exitErr) {
t.Fatalf("expected *errs.NetworkError, got %T: %v", err, err) t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
} }
if netErr.Subtype != errs.SubtypeNetworkTransport { if exitErr.Code != output.ExitNetwork {
t.Errorf("subtype = %q, want %q", netErr.Subtype, errs.SubtypeNetworkTransport) 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) { func TestUpdateInvalidVersion_JSON(t *testing.T) {
@@ -579,12 +503,12 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
if err == nil { if err == nil {
t.Fatal("expected verification failure") t.Fatal("expected verification failure")
} }
var bareErr *output.BareError var exitErr *output.ExitError
if !errors.As(err, &bareErr) { if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err) t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
} }
if bareErr.Code != output.ExitAPI { if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, bareErr.Code) t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code)
} }
out := stdout.String() 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 OAuth shapes; and **framework + business code** producing errors. This file
is the single source of truth for all three. 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 ## Invariants
1. Every error belongs to exactly one **Category**. The set is closed 1. Every error belongs to exactly one **Category**. The set is closed
(`errs/category.go`); adding a member requires deliberate review. (`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`. lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
Undeclared subtypes fail CI. Every error path constructs a typed Undeclared subtypes fail CI. The constraint applies only to typed
`*errs.*` error at its origin, so the constraint applies uniformly. `*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 3. **`Category` + `Subtype`** are wire-stable identifiers consumers may
branch on. Renaming either is a breaking change. branch on. Renaming either is a breaking change.
4. `Code` is the upstream numeric code when known (e.g. Lark API code). 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. unchanged across the `errors.As` / `errors.Unwrap` chain.
7. For the typed-envelope path, exit codes derive from `Category` only 7. For the typed-envelope path, exit codes derive from `Category` only
via `output.ExitCodeForCategory` — including `SecurityPolicyError`, via `output.ExitCodeForCategory` — including `SecurityPolicyError`,
which exits `6` via `CategoryPolicy`. `output.ErrBare(code)` is the which exits `6` via `CategoryPolicy`. Unmigrated `*output.ExitError`
exception: it constructs an `*output.BareError`, a deliberate producers still carry a hand-set `Code` until they finish migrating.
silent-exit signal (stdout already carries the answer) that bypasses `output.ErrBare(code)` is the lone exception: a deliberate
the envelope (see **Predicate commands** below). predicate-command signal that bypasses the envelope (see
**Predicate commands** below).
## Wire format ## 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.hint` | informational | actionable recovery guidance |
| `error.log_id` | informational | upstream request id (server-side trace) | | `error.log_id` | informational | upstream request id (server-side trace) |
| `error.retryable` | wire-stable | `true` when present; omitted when `false` | | `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` | | per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
`SecurityPolicyError` renders through the same typed envelope as every `SecurityPolicyError` renders through the same typed envelope as every
other category. `error.type` is `"policy"`, `error.subtype` is one of other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via `challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`. `CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been
retired.
## Categories ## Categories
@@ -110,21 +119,20 @@ Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
cmd/root.go handleRootError dispatches: cmd/root.go handleRootError dispatches:
├─ output.ErrBare(code) → no envelope (stdout already written); exit = code
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err) ├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6; │ (includes *errs.SecurityPolicyError → policy envelope, exit 6)
│ *errs.ConfigError, constructed typed at origin) ├─ *core.ConfigError → promoted to typed via errcompat ↑
├─ *output.PartialFailureError → no stderr envelope (ok:false result already on stdout); exit = code ├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
*output.BareError → no envelope (stdout already written); exit = code untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
└─ Cobra usage error → typed validation envelope (invalid_argument); exit 2
``` ```
The dispatcher emits a JSON envelope on stderr for both the typed branch and Only the typed and `*output.ExitError` branches emit a JSON envelope on
residual Cobra usage errors (missing required flag, unknown command, stderr. Untyped errors (including Cobra's "required flag missing" / unknown
argument validation): the latter are classified into a typed validation subcommand messages) print plain text and exit `1` — consumers must
envelope (`invalid_argument`) and exit `2`, matching the explicit flag and tolerate that fallback.
subcommand guards.
### Predicate commands (`output.BareError`) ### Predicate commands (`output.ErrBare`)
A small class of commands is **predicates**: they answer a yes/no A small class of commands is **predicates**: they answer a yes/no
question and signal the answer through the shell exit code so callers 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: These commands deliberately:
1. write a structured JSON answer to **stdout** themselves, and 1. write a structured JSON answer to **stdout** themselves, and
2. return `output.ErrBare(exitCode)` — an `*output.BareError` to 2. return `output.ErrBare(exitCode)` to communicate the exit code to
communicate the exit code to the dispatcher without producing a the dispatcher without producing a `stderr` envelope.
`stderr` envelope.
`*output.BareError` is **not** an error in the typed-envelope sense — it `output.ErrBare` is **not** an error in the typed-envelope sense — it
carries no category, subtype, or message, only an exit code. It is a carries no category, subtype, or message. It is a one-bit output-
one-bit output-control signal that lives outside the contract for the control signal that lives outside the contract for the same reason
same reason `grep -q` / `diff` / `systemctl is-active` set non-zero exit `grep -q` / `diff` / `systemctl is-active` set non-zero exit codes
codes without printing anything to stderr: pollution of stderr by a without printing anything to stderr: pollution of stderr by a
predicate's negative answer would break `2>/dev/null` log hygiene in predicate's negative answer would break `2>/dev/null` log hygiene in
caller scripts. caller scripts.
A second class also uses `ErrBare`: a command that emits its own complete New code should not reach for `ErrBare` unless the command is
structured result envelope on **stdout** under `--json` (e.g. `update`, whose genuinely a predicate. Anything carrying recoverable error content
`{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`
belongs in a typed `*errs.XxxError` — or, for a batch result, in the belongs in a typed `*errs.XxxError` — or, for a batch result, in the
partial-failure outcome below. partial-failure outcome below.
@@ -214,7 +214,7 @@ exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors
out=$(lark-cli ... 2>&1) out=$(lark-cli ... 2>&1)
code=$? 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 if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
printf '%s\n' "$out" >&2 printf '%s\n' "$out" >&2
exit "$code" 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 maps `Category` to the shell code. A new exit-code requirement means a
new `Category`, not a one-off override at the call site. new `Category`, not a one-off override at the call site.
(The only exits not derived from `Category` are the (Legacy `*output.ExitError` retains hand-set codes until removal;
`*output.BareError` and the `*output.PartialFailureError` signals, which `SecurityPolicyError` retains a hand-set code on main until the framework
carry their own code by design and sit outside the typed-envelope contract — migration PR retires the carve-out — see **Migration**.)
see **Predicate commands**.)
#### Split `Message`, `Hint`, and `Cause` #### 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 // 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 When a `*ValidationError` originates from a flag value, `Param` holds the
optional; a producer sets whichever fits the failure. 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 For positional arguments, use the canonical name without dashes
`*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
(`"target_user_id"`). (`"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 ### Constructing typed errors
Prefer the **builder API**. The constructor pins `Category` + `Subtype` + 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 once and wrapped per Category branch. Outside that pattern, new code
should reach for the builder. should reach for the builder.
When the validation logic outgrows a single range check — multiple flags, Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
format parsing, conditional rules — extract it into a helper that also returns remain callable during migration but are `// Deprecated:` — new code goes
the typed `*errs.ValidationError`; the helper, not `Execute`, sets `Param` (a through the builder.
helper bound to one shortcut is normal in this codebase; see `parseTimeRange`
in `shortcuts/calendar/calendar_agenda.go`). #### 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 ### 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. 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`. 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 `CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the top-level wire fields are forbidden — per-Subtype data goes into the
@@ -495,33 +488,19 @@ top level.
## CI guards ## CI guards
Two golangci-lint rules and the custom `errscontract` AST module enforce the | Check | Enforces | Where |
contract; CI runs all three on every PR. |-------|----------|-------|
| 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, CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The
so this spec cannot drift from the lint config): 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;
| Rule | Enforces | see `lint/README.md` for how to add a new lint domain.
|------|----------|
| 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.
## Stability ## 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 | | 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 | | 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 ## Troubleshooting
**Envelope shows `type=api subtype=unknown` for what should be a more **Envelope shows `type=api subtype=unknown` for what should be a more
specific category.** The Lark code is unknown to `LookupCodeMeta` and fell specific category.** The Lark code is unknown to `LookupCodeMeta` and fell
through to the generic bucket (`internal/errclass/classify.go`). Add the through to the generic bucket (`internal/errclass/classify.go`). Add the
code to `internal/errclass/codemeta_<service>.go` with the right Category 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 **Envelope shows `type=internal subtype=sdk_error`.** Origin is
`client.WrapDoAPIError` taking the non-transport branch `client.WrapDoAPIError` taking the non-transport branch
@@ -580,6 +613,8 @@ string cannot be classified retroactively.
- *Add a new condition?* → **Add a Subtype** - *Add a new condition?* → **Add a Subtype**
- *Consume from a shell script?* → **Consumers / Shell / AI** - *Consume from a shell script?* → **Consumers / Shell / AI**
- *Understand or fix a CI failure?* → **CI guards** - *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` - *Read source.* → `errs/doc.go``errs/category.go``errs/types.go`
`errs/predicates.go``internal/errclass/` `errs/predicates.go``internal/errclass/`
`cmd/root.go` `handleRootError`. `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 ( const (
SubtypeChallengeRequired Subtype = "challenge_required" // user must complete browser challenge / MFA SubtypeChallengeRequired Subtype = "challenge_required" // user must complete browser challenge / MFA
SubtypeAccessDenied Subtype = "access_denied" // policy denies access outright SubtypeAccessDenied Subtype = "access_denied" // policy denies access outright
SubtypeContentSafety Subtype = "content_safety" // content-safety scanner blocked output in block mode
) )
// CategoryInternal subtypes // CategoryInternal subtypes

View File

@@ -77,10 +77,6 @@ type ValidationError struct {
type InvalidParam struct { type InvalidParam struct {
Name string `json:"name"` Name string `json:"name"`
Reason string `json:"reason"` 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 // 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 // interface would panic when the root dispatcher or any caller walks the
// errors.Is / errors.Unwrap chain. // errors.Is / errors.Unwrap chain.
// //
// The doc comments on these types claim "nil-receiver safe"; this test // The doc comments on these types claim "nil-receiver safe" but until this
// pins that claim so the behavioral comment cannot silently drift from the // test landed nothing actually pinned that claim — exactly the
// implementation. // behavioral-comment-without-test footgun caught in PR #984 review.
func TestTypedErrors_UnwrapNilReceiver(t *testing.T) { func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
t.Helper() t.Helper()
checks := []struct { checks := []struct {

View File

@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"` ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"` 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"` 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) { 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 msg := envelope.Event.Message
var content string content := msg.Content
if msg.MessageType == "interactive" { if msg.MessageType != "interactive" {
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
} else {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{ content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
RawContent: msg.Content, RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions), MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),

View File

@@ -7,8 +7,8 @@ import "fmt"
// AbortError is returned by a Wrapper that wants to short-circuit the // AbortError is returned by a Wrapper that wants to short-circuit the
// command chain (instead of calling next). The framework converts it // command chain (instead of calling next). The framework converts it
// to a typed errs.* error so the JSON envelope carries the structured // to an *output.ExitError with type "hook" so the JSON envelope carries
// fields agents expect. // the structured fields agents expect.
// //
// HookName is the framework-namespaced name ("secaudit.approval"); the // HookName is the framework-namespaced name ("secaudit.approval"); the
// Registrar adds the plugin-name prefix automatically. // 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 // CommandDeniedError is the structured error returned by a denyStub. Every
// pruned-command execution path -- direct invocation, alias expansion, // pruned-command execution path -- direct invocation, alias expansion,
// internal call -- returns this exact type. The dispatcher converts it to a // internal call -- returns this exact type. It is wire-compatible with the
// typed errs.* error; the Layer field carries the denial layer for the // output.ExitError envelope via the Layer (== error.type) field and the
// envelope. // detail map produced by ExitError().
// //
// Layer values: // 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 ( import (
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
@@ -21,10 +22,7 @@ var TokenRetryCodes = map[int]bool{
output.LarkErrTokenExpired: true, output.LarkErrTokenExpired: true,
} }
// NeedAuthorizationError is the sentinel preserved in the Cause chain of the // NeedAuthorizationError is thrown when no valid UAT exists.
// 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.
type NeedAuthorizationError struct { type NeedAuthorizationError struct {
UserOpenId string UserOpenId string
} }
@@ -34,31 +32,24 @@ func (e *NeedAuthorizationError) Error() string {
return fmt.Sprintf("%s (user: %s)", needUserAuthorizationMarker, e.UserOpenId) 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 // IsNeedUserAuthorizationError reports whether err represents a missing-UAT
// failure. It matches the legacy *NeedAuthorizationError sentinel, which is // failure, either as the original auth error or as a wrapped ExitError.
// preserved in the Cause chain of the typed missing-UAT error, so errors.As
// traverses into the typed *errs.AuthenticationError as well.
func IsNeedUserAuthorizationError(err error) bool { func IsNeedUserAuthorizationError(err error) bool {
if err == nil { if err == nil {
return false return false
} }
var needAuthErr *NeedAuthorizationError 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 // SecurityPolicyError is preserved as a Go type alias so existing

View File

@@ -6,7 +6,7 @@ package auth
import ( import (
"testing" "testing"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output"
) )
func TestIsNeedUserAuthorizationError(t *testing.T) { 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) { t.Run("wrapped exit error", func(t *testing.T) {
// The typed constructor preserves the legacy sentinel in the Cause err := output.ErrNetwork("API call failed: %s", &NeedAuthorizationError{})
// chain, so errors.As traverses into it. if !IsNeedUserAuthorizationError(err) {
if !IsNeedUserAuthorizationError(NewNeedUserAuthorizationError("u_1")) { t.Fatal("expected wrapped ExitError to match")
t.Fatal("expected typed missing-UAT error to match via its cause chain")
} }
}) })
t.Run("other error", func(t *testing.T) { 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) { if IsNeedUserAuthorizationError(err) {
t.Fatal("expected unrelated error not to match") 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) { func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, error) {
stored := GetStoredToken(opts.AppId, opts.UserOpenId) stored := GetStoredToken(opts.AppId, opts.UserOpenId)
if stored == nil { if stored == nil {
return "", NewNeedUserAuthorizationError(opts.UserOpenId) return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
} }
status := TokenStatus(stored) status := TokenStatus(stored)
@@ -86,7 +86,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
return "", err return "", err
} }
if refreshed == nil { if refreshed == nil {
return "", NewNeedUserAuthorizationError(opts.UserOpenId) return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
} }
return refreshed.AccessToken, nil 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) 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. // refreshWithLock acquires a file lock before attempting to refresh the token.

View File

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

View File

@@ -19,9 +19,11 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core" larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util" "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) { if errors.As(err, &unavailableErr) {
return "", newTokenMissingError(as, unavailableErr) return "", newTokenMissingError(as, unavailableErr)
} }
// The credential chain already emits a typed *errs.AuthenticationError // NeedAuthorizationError from the credential chain (e.g. UAT refresh
// for the missing-UAT case (e.g. UAT refresh returned // returned need_user_authorization) must surface as typed
// need_user_authorization), so it flows through unchanged: the // AuthenticationError. Without this, WrapDoAPIError would wrap the
// outer-typed gate in cmd/root.go and the idempotent WrapDoAPIError // raw err as NetworkError, and cmd/root.go's outer-typed gate would
// both preserve its authentication category and exit 3. // 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 return "", err
} }
if result.Token == "" { 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 // SDK Do() failures are normalised through WrapDoAPIError so every caller
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without // (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without
// each one remembering to wrap. WrapDoAPIError classifies a raw transport // each one remembering to wrap. Today that wire shape is still the legacy
// failure into a typed *errs.NetworkError / *errs.InternalError per the // *output.ExitError envelope (network / api_error); future framework-
// contract in errs/ERROR_CONTRACT.md. Errors that arrive already-classified // boundary migration flips WrapDoAPIError to typed *errs.NetworkError /
// (a typed *errs.* from resolveAccessToken's missing-credential paths or // *errs.InternalError per the contract in errs/ERROR_CONTRACT.md.
// elsewhere) flow through unchanged. // 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) { func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
var opts []larkcore.RequestOptionFunc var opts []larkcore.RequestOptionFunc
token, err := c.resolveAccessToken(ctx, as) token, err := c.resolveAccessToken(ctx, as)
if err != nil { if err != nil {
// WrapDoAPIError is idempotent on already-classified errors: // WrapDoAPIError is idempotent on already-classified errors:
// the typed *errs.AuthenticationError that resolveAccessToken returns // the *output.ExitError that resolveAccessToken returns for missing
// for missing tokens passes through with its auth category and exit 3 // tokens (via output.ErrAuth) passes through with its auth category
// intact, and any other typed *errs.* error from the credential chain // and exit 3 intact, and any future typed *errs.* error from the
// survives the same way. Only stray untyped errors (raw fmt.Errorf) // credential chain survives the same way. Only stray untyped errors
// get the transport-or-internal fallback. // (raw fmt.Errorf) get the transport-or-internal fallback.
return nil, WrapDoAPIError(err) return nil, WrapDoAPIError(err)
} }
if as.IsBot() { 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 // Auth is resolved via Credential (same as DoSDKRequest). Security headers and
// any extra headers from opts are applied automatically. // any extra headers from opts are applied automatically.
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB), // 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) { func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
cfg := buildConfig(opts) 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 // JSON parse failures are wrapped via WrapJSONResponseParseError so callers
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service) // (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
// see a typed *errs.InternalError (invalid_response) instead of a bare // see an *output.ExitError envelope (api_error for malformed JSON, network
// fmt.Errorf — otherwise an empty or malformed page body would surface to the // for everything else) instead of a bare fmt.Errorf — otherwise an empty
// root handler as a plain-text "Error: ..." line and bypass the JSON stderr // or malformed page body would surface to the root handler as a plain-text
// envelope contract. // "Error: ..." line and bypass the JSON stderr envelope contract.
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) { func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
resp, err := c.DoAPI(ctx, request) resp, err := c.DoAPI(ctx, request)
if err != nil { 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), // 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. // 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 allResults []interface{}
var pageToken string var pageToken string
page := 0 page := 0
@@ -390,9 +399,7 @@ func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opt
} }
if onResult != nil { if onResult != nil {
if err := onResult(result); err != nil { onResult(result)
return allResults, err
}
} }
allResults = append(allResults, 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. // 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, // 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). // 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 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{}) resultMap, ok := r.(map[string]interface{})
if !ok { if !ok {
return nil return
} }
data, ok := resultMap["data"].(map[string]interface{}) data, ok := resultMap["data"].(map[string]interface{})
if !ok { if !ok {
return nil return
} }
arrayField := output.FindArrayField(data) arrayField := output.FindArrayField(data)
if arrayField == "" { if arrayField == "" {
return nil return
} }
items, ok := data[arrayField].([]interface{}) items, ok := data[arrayField].([]interface{})
if !ok { if !ok {
return nil return
} }
totalItems += len(items) totalItems += len(items)
if err := onItems(items); err != nil { onItems(items)
return err
}
hasItems = true hasItems = true
return nil
}) })
if loopErr != nil { if loopErr != nil {
return nil, false, loopErr return nil, false, loopErr

View File

@@ -124,9 +124,8 @@ func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) {
Method: "GET", Method: "GET",
URL: "/open-apis/contact/v3/users/u123", URL: "/open-apis/contact/v3/users/u123",
As: "bot", As: "bot",
}, func(items []interface{}) error { }, func(items []interface{}) {
t.Error("onItems should not be called for non-batch API") t.Error("onItems should not be called for non-batch API")
return nil
}, PaginationOptions{}) }, PaginationOptions{})
if err != nil { if err != nil {
@@ -169,9 +168,8 @@ func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
Method: "GET", Method: "GET",
URL: "/open-apis/contact/v3/users", URL: "/open-apis/contact/v3/users",
As: "bot", As: "bot",
}, func(items []interface{}) error { }, func(items []interface{}) {
streamedItems = append(streamedItems, items...) streamedItems = append(streamedItems, items...)
return nil
}, PaginationOptions{}) }, PaginationOptions{})
if err != nil { 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) { func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
apiCalls := 0 apiCalls := 0
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { 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 // TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError pins that
// the missing-token path of resolveAccessToken returns the typed // 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) { func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
ac := &APIClient{ ac := &APIClient{
HTTP: &http.Client{}, HTTP: &http.Client{},
@@ -553,22 +500,24 @@ func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T
} }
} }
// needAuthTokenResolver mirrors the production credential chain: the // needAuthTokenResolver returns *internalauth.NeedAuthorizationError to
// missing-UAT case is constructed typed at the source (internal/auth) and // exercise the P1 regression path: a credential chain that signals
// carries the legacy *NeedAuthorizationError sentinel in its Cause chain. It // "user must re-authorize" must surface as typed AuthenticationError, not
// must surface as a typed AuthenticationError and flow through resolveAccessToken // fall through to the generic err return which WrapDoAPIError would then
// and WrapDoAPIError unchanged (never mis-classified as NetworkError). // 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 { type needAuthTokenResolver struct {
userOpenID string userOpenID string
} }
func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) { 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 // TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication
// pins that the typed missing-UAT error from the credential chain reaches the // is the codex P1 regression test: without this branch, the credential
// caller as a typed AuthenticationError with the marker and sentinel intact. // chain's NeedAuthorizationError would propagate raw and WrapDoAPIError
// would mis-classify it as NetworkError.
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) { func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
ac := &APIClient{ ac := &APIClient{
HTTP: &http.Client{}, HTTP: &http.Client{},
@@ -674,7 +623,7 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint // *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint
// preserved on Problem.Hint. Pagination / cmd/api / cmd/service callers see // preserved on Problem.Hint. Pagination / cmd/api / cmd/service callers see
// the typed JSON stderr envelope (exit 5/internal) — wire `type` is // the typed JSON stderr envelope (exit 5/internal) — wire `type` is
// "internal". // "internal", not the legacy "api_error".
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) { func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) { rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{ return &http.Response{

View File

@@ -4,6 +4,7 @@
package client package client
import ( import (
"context"
"fmt" "fmt"
"io" "io"
@@ -18,6 +19,33 @@ type PaginationOptions struct {
Identity core.Identity // identity passed to checkErr; defaults to AsUser when empty 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{} { func mergePagedResults(w io.Writer, results []interface{}) interface{} {
if len(results) == 0 { if len(results) == 0 {
return map[string]interface{}{} return map[string]interface{}{}

View File

@@ -41,26 +41,6 @@ type ResponseOptions struct {
CheckError func(result interface{}, identity core.Identity) error 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: // HandleResponse routes a raw *larkcore.ApiResp to the appropriate output:
// 1. If Content-Type is JSON, check for business errors first (even with --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. // 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 // Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly
// directly instead of falling through to the binary-save path. // 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 != "" { 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. // JSON responses: always check for business errors before saving.
if IsJSONContentType(ct) || ct == "" { if IsJSONContentType(ct) || ct == "" {
result, err := ParseJSONResponse(resp) result, err := ParseJSONResponse(resp)
if err != nil { 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) return WrapJSONResponseParseError(err, resp.RawBody)
} }
if apiErr := check(result, identity); apiErr != nil { if apiErr := check(result, identity); apiErr != nil {
return apiErr return apiErr
} }
// CheckResponse treats business code 0 as success, so a 4xx/5xx whose // Content safety scanning
// JSON body omits a non-zero code would otherwise be served as a scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
// successful result. Classify by HTTP status so it is never swallowed. if scanResult.Blocked {
if resp.StatusCode >= 400 { return scanResult.BlockErr
return httpStatusError(resp.StatusCode, resp.RawBody)
} }
if opts.OutputPath != "" { 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 { if scanResult.Alert != nil {
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert) output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
} }
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out) 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 { if scanResult.Alert != nil {
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert) output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
} }
if opts.JqExpr != "" {
return output.JqFilter(opts.Out, result, opts.JqExpr)
}
output.FormatValue(opts.Out, result, opts.Format) output.FormatValue(opts.Out, result, opts.Format)
return nil return nil
} }

View File

@@ -5,7 +5,6 @@ package client
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"io" "io"
"net/http" "net/http"
@@ -17,7 +16,6 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core" larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio" "github.com/larksuite/cli/internal/vfs/localfileio"
) )
@@ -209,54 +207,15 @@ func TestHandleResponse_JSON(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
var errOut bytes.Buffer var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{ err := HandleResponse(resp, ResponseOptions{
Identity: core.AsBot, Out: &out,
Out: &out, ErrOut: &errOut,
ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{},
FileIO: &localfileio.LocalFileIO{},
}) })
if err != nil { if err != nil {
t.Fatalf("HandleResponse failed: %v", err) t.Fatalf("HandleResponse failed: %v", err)
} }
var got map[string]interface{} if !bytes.Contains(out.Bytes(), []byte(`"code"`)) {
if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Errorf("expected JSON output, got: %s", out.String())
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")
} }
} }
@@ -274,12 +233,6 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
if err == nil { if err == nil {
t.Error("expected error for non-zero code") 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) { 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) { func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
origWd, _ := os.Getwd() origWd, _ := os.Getwd()

View File

@@ -34,24 +34,10 @@ import (
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
) )
// Source identifies how a command entered the repository-owned command tree. // domainAnnotationKey is the cobra Annotation key for the business domain.
type Source string // Kept distinct from cmdutil.* keys so this package can evolve without
// disturbing existing readers.
const ( const domainAnnotationKey = "cmdmeta.domain"
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"
)
// Meta groups the three command-level metadata axes consumed by the policy // Meta groups the three command-level metadata axes consumed by the policy
// engine and hook selectors. // engine and hook selectors.
@@ -107,24 +93,6 @@ func SetDomain(cmd *cobra.Command, domain string) {
cmd.Annotations[domainAnnotationKey] = domain 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 // Domain returns the nearest-ancestor domain for the command. Empty string
// when no ancestor has the annotation -- this is the "unknown" state the // when no ancestor has the annotation -- this is the "unknown" state the
// policy engine must treat as ALLOW. // policy engine must treat as ALLOW.
@@ -140,33 +108,6 @@ func Domain(cmd *cobra.Command) string {
return "" 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). // Risk returns the nearest-ancestor risk level (via cmdutil.GetRisk).
// ok=false signals "unknown" -- the policy engine treats this as // ok=false signals "unknown" -- the policy engine treats this as
// fail-closed (deny with risk_not_annotated) whenever a Rule without // 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) 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 package cmdpolicy_test
import ( import (
"encoding/json"
"errors" "errors"
"strings" "strings"
"testing" "testing"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil" "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 // Apply with the wrapped *output.ExitError exposes BOTH paths consumers
// consumers rely on: // rely on:
// 1. cmd/root.go's envelope writer (errs.ProblemOf / failed_precondition // 1. cmd/root.go's envelope writer (errors.As on *output.ExitError)
// subtype + exit code 2) // 2. in-process consumers extracting the platform.CommandDeniedError
// 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.
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) { func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
root := buildTree() root := buildTree()
denied := map[string]cmdpolicy.Denial{ denied := map[string]cmdpolicy.Denial{
@@ -196,33 +191,31 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
t.Fatalf("denied command should return error") t.Fatalf("denied command should return error")
} }
// Path 1: typed-envelope view. The denial is a failed_precondition // Path 1: envelope-writer view.
// ValidationError so cmd/root.go renders the structured envelope and var exitErr *output.ExitError
// the process exits 2 (ExitValidation). if !errors.As(err, &exitErr) {
var ve *errs.ValidationError t.Fatalf("error chain must contain *output.ExitError, got %T", err)
if !errors.As(err, &ve) {
t.Fatalf("error chain must contain *errs.ValidationError, got %T", err)
} }
if ve.Subtype != errs.SubtypeFailedPrecondition { if exitErr.Detail == nil {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition) t.Fatalf("ExitError.Detail required for envelope to render")
} }
if code := output.ExitCodeOf(err); code != output.ExitValidation { if exitErr.Detail.Type != "command_denied" {
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation) t.Errorf("envelope error.type = %q, want command_denied", exitErr.Detail.Type)
} }
// The policy metadata is folded into the Hint text: reason_code, // JSON envelope shape: detail.reason_code must be present and
// policy_source, and rule_name must all be discoverable there. // match the closed enum.
if !strings.Contains(ve.Hint, "write_not_allowed") { detailMap, ok := exitErr.Detail.Detail.(map[string]any)
t.Errorf("hint must carry reason_code write_not_allowed, got %q", ve.Hint) if !ok {
t.Fatalf("envelope detail should be map[string]any, got %T", exitErr.Detail.Detail)
} }
if !strings.Contains(ve.Hint, "plugin:secaudit") { if detailMap["reason_code"] != "write_not_allowed" {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint) t.Errorf("detail.reason_code = %v, want write_not_allowed", detailMap["reason_code"])
} }
if !strings.Contains(ve.Hint, "secaudit-policy") { if detailMap["policy_source"] != "plugin:secaudit" {
t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint) t.Errorf("detail.policy_source = %v, want plugin:secaudit", detailMap["policy_source"])
} }
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError // Path 2: in-process typed-error view.
// is preserved as the Cause so errors.As still reaches it.
var cd *platform.CommandDeniedError var cd *platform.CommandDeniedError
if !errors.As(err, &cd) { if !errors.As(err, &cd) {
t.Fatalf("error chain must expose *platform.CommandDeniedError") 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" { if cd.Path != "docs/+update" || cd.ReasonCode != "write_not_allowed" {
t.Errorf("CommandDeniedError = %+v", cd) 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 // Regression: a pure parent group carrying AnnotationPureGroup must be

View File

@@ -6,8 +6,8 @@ package cmdpolicy
import ( import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/output"
) )
// Apply walks the command tree and installs denyStubs for every path in // Apply walks the command tree and installs denyStubs for every path in
@@ -24,11 +24,12 @@ import (
// cobra would intercept the call // cobra would intercept the call
// with "missing required flag" // with "missing required flag"
// before we can return our error // before we can return our error
// 3. cmd.RunE = denyStub(denial) -- returns a typed // 3. cmd.RunE = denyStub(denial) -- returns *output.ExitError so
// *errs.ValidationError so
// cmd/root.go's envelope writer // cmd/root.go's envelope writer
// emits structured JSON; the // emits structured JSON (with
// wrapped error chain still // error.type = denial.Layer and
// detail.reason_code = ReasonCode);
// the wrapped error chain still
// exposes *platform.CommandDeniedError // exposes *platform.CommandDeniedError
// via errors.As for in-process // via errors.As for in-process
// consumers // consumers
@@ -111,17 +112,42 @@ func CommandDeniedFromDenial(path string, d Denial) *platform.CommandDeniedError
} }
} }
// BuildDenialError is the default typed error for user-layer denials: // DenialDetailMap is the canonical detail.* shape every `command_denied`
// Message comes from CommandDeniedError.Error(); the policy layer, source, // envelope shares (see docs/extension/reason-codes.md). Use it as
// rule name, and reason code are folded into the Hint. The // ErrDetail.Detail when constructing an envelope outside BuildDenialError.
// *platform.CommandDeniedError is preserved as the Cause so errors.As func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any {
// works for in-process consumers. return map[string]any{
func BuildDenialError(path string, d Denial) *errs.ValidationError { "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) cd := CommandDeniedFromDenial(path, d)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", cd.Error()). return &output.ExitError{
WithHint("denied by %s policy (source %s, rule %q, reason_code %s); adjust the policy configuration to allow this command", Code: output.ExitValidation,
cd.Layer, cd.PolicySource, cd.RuleName, cd.ReasonCode). Detail: &output.ErrDetail{
WithCause(cd) Type: "command_denied",
Message: cd.Error(),
Detail: DenialDetailMap(cd),
},
Err: cd,
}
} }
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go // 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 denial := d // capture by value for the closure
cmd.RunE = func(c *cobra.Command, args []string) error { cmd.RunE = func(c *cobra.Command, args []string) error {
// The typed message carries the user-facing semantic ("a command // error.type is the user-facing semantic ("a command was denied by
// was denied"); the hint carries the layer / source / rule // policy"). detail.layer carries the implementation distinction
// distinction ("policy" vs "strict_mode") for debugging. // ("policy" vs "strict_mode") for debugging.
return BuildDenialError(path, denial) return BuildDenialError(path, denial)
} }
// Clear any pre-existing Run hook: cobra prefers RunE when both are // 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. // aggregation), which the Apply step consumes to install denyStubs.
// //
// This package only implements the user-layer half. Strict-mode is handled // 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 // by cmd/prune.go, which produces command_denied envelopes of the same
// (failed_precondition, *platform.CommandDeniedError preserved as Cause) so // shape via BuildDenialError so external agents can dispatch on
// external agents see a uniform envelope regardless of which layer rejected // detail.layer / reason_code uniformly regardless of which layer rejected
// the call. // the call.
package cmdpolicy package cmdpolicy

View File

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

View File

@@ -4,22 +4,39 @@
package cmdutil package cmdutil
import ( import (
"github.com/larksuite/cli/errs" "fmt"
"github.com/larksuite/cli/internal/output"
) )
// RequireConfirmation constructs a typed *errs.ConfirmationRequiredError // RequireConfirmation constructs a confirmation_required error with exit code
// (exit code ExitConfirmationRequired) carrying the risk level and action as // ExitConfirmationRequired and a structured Risk envelope. Used by both
// typed extension fields. Used by both shortcut and service command execution // shortcut and service command execution paths when a statically
// paths when a statically high-risk-write operation has not been confirmed // high-risk-write operation has not been confirmed with --yes.
// with --yes.
// //
// action identifies the operation for the agent (e.g. "mail +send", // action identifies the operation for the agent (e.g. "mail +send",
// "drive.files.delete"). The envelope does not carry a pre-built retry // "drive.files.delete"). The envelope does not carry a pre-built retry
// command: agents already know their original invocation and only need to // command: agents already know their original invocation and only need to
// append --yes per the hint, which keeps the protocol free of shell-quoting // append --yes per the hint, which keeps the protocol free of shell-quoting
// pitfalls. // 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 { func RequireConfirmation(action string) error {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action, return &output.ExitError{
"%s requires confirmation", action). Code: output.ExitConfirmationRequired,
WithHint("add --yes to confirm") 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" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
) )
func TestRequireConfirmation_TypedShape(t *testing.T) { func TestRequireConfirmation_EnvelopeShape(t *testing.T) {
err := RequireConfirmation("drive +delete") err := RequireConfirmation("drive +delete")
if err == nil { if err == nil {
t.Fatal("expected non-nil error") t.Fatal("expected non-nil error")
} }
var cre *errs.ConfirmationRequiredError var exitErr *output.ExitError
if !errors.As(err, &cre) { if !errors.As(err, &exitErr) {
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err) t.Fatalf("expected *output.ExitError, got %T", err)
} }
if cre.Category != errs.CategoryConfirmation { if exitErr.Code != output.ExitConfirmationRequired {
t.Errorf("Category = %q, want %q", cre.Category, errs.CategoryConfirmation) t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitConfirmationRequired)
} }
if cre.Subtype != errs.SubtypeConfirmationRequired { if exitErr.Detail == nil {
t.Errorf("Subtype = %q, want %q", cre.Subtype, errs.SubtypeConfirmationRequired) t.Fatal("Detail is nil")
} }
if got := output.ExitCodeOf(err); got != output.ExitConfirmationRequired { d := exitErr.Detail
t.Errorf("ExitCodeOf = %d, want %d", got, output.ExitConfirmationRequired) 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") { 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'", cre.Message) t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", d.Message)
} }
if cre.Hint != "add --yes to confirm" { if d.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint) t.Errorf("Hint = %q, want 'add --yes to confirm'", d.Hint)
} }
if cre.Risk != errs.RiskHighRiskWrite { if d.Risk == nil {
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite) t.Fatal("Risk is nil")
} }
if cre.Action != "drive +delete" { if d.Risk.Level != "high-risk-write" {
t.Errorf("Action = %q, want drive +delete", cre.Action) 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) { func TestRequireConfirmation_JSONShape(t *testing.T) {
err := RequireConfirmation("mail +send") err := RequireConfirmation("mail +send")
var cre *errs.ConfirmationRequiredError var exitErr *output.ExitError
if !errors.As(err, &cre) { if !errors.As(err, &exitErr) {
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err) t.Fatalf("expected *output.ExitError, got %T", err)
} }
raw, mErr := json.Marshal(cre) raw, mErr := json.Marshal(exitErr.Detail)
if mErr != nil { if mErr != nil {
t.Fatalf("marshal: %v", mErr) 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) t.Errorf("unexpected fix_command present in JSON: %s", raw)
} }
if back["risk"] != "high-risk-write" { risk, ok := back["risk"].(map[string]interface{})
t.Errorf("risk in JSON = %v", back["risk"]) if !ok {
t.Fatalf("risk block missing in JSON: %s", raw)
} }
if back["action"] != "mail +send" { if risk["level"] != "high-risk-write" {
t.Errorf("action in JSON = %v", back["action"]) 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. // 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) t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
} }
} }

View File

@@ -8,7 +8,6 @@ import (
"errors" "errors"
"testing" "testing"
"github.com/larksuite/cli/errs"
_ "github.com/larksuite/cli/extension/credential/env" _ "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
@@ -108,9 +107,9 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
if err == nil { if err == nil {
t.Fatal("Config() error = nil, want non-nil") t.Fatal("Config() error = nil, want non-nil")
} }
var cfgErr *errs.ConfigError var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) { 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` { if cfgErr.Message != `profile "missing" not found` {
t.Fatalf("Config() error message = %q, want %q", 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" "strconv"
"strings" "strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core" 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") _, filePath, isStdin := ParseFileFlag(file, "file")
if !isStdin && filePath == "" { if !isStdin && filePath == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: empty file path"). return output.ErrValidation("--file: empty file path")
WithParam("--file")
} }
if outputPath != "" { if outputPath != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --output are mutually exclusive").WithParams( return output.ErrValidation("--file and --output are mutually exclusive")
errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --output"},
errs.InvalidParam{Name: "--output", Reason: "mutually exclusive with --file"},
)
} }
if pageAll { if pageAll {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --page-all are mutually exclusive").WithParams( return output.ErrValidation("--file and --page-all are mutually exclusive")
errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --page-all"},
errs.InvalidParam{Name: "--page-all", Reason: "mutually exclusive with --file"},
)
} }
if isStdin && data == "-" { if isStdin && data == "-" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --data cannot both read from stdin").WithParams( return output.ErrValidation("--file and --data cannot both read from stdin")
errs.InvalidParam{Name: "--file", Reason: "only one flag may read from stdin"},
errs.InvalidParam{Name: "--data", Reason: "only one flag may read from stdin"},
)
} }
if isStdin && params == "-" { if isStdin && params == "-" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --params cannot both read from stdin").WithParams( return output.ErrValidation("--file and --params cannot both read from stdin")
errs.InvalidParam{Name: "--file", Reason: "only one flag may read from stdin"},
errs.InvalidParam{Name: "--params", Reason: "only one flag may read from stdin"},
)
} }
switch httpMethod { switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE": case "POST", "PUT", "PATCH", "DELETE":
default: default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file requires POST, PUT, PATCH, or DELETE method"). return output.ErrValidation("--file requires POST, PUT, PATCH, or DELETE method")
WithParam("--file").
WithHint("file upload only applies to write methods; remove --file for read methods")
} }
return nil return nil
@@ -98,35 +83,25 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
if isStdin { if isStdin {
if stdin == nil { if stdin == nil {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "--file: stdin is not available"). return nil, output.ErrValidation("--file: stdin is not available")
WithParam("--file").
WithHint("pipe the file content to stdin, or pass a file path instead of \"-\"")
} }
data, err := io.ReadAll(stdin) data, err := io.ReadAll(stdin)
if err != nil { if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read stdin: %v", err). return nil, output.ErrValidation("--file: failed to read stdin: %v", err)
WithParam("--file").
WithCause(err)
} }
if len(data) == 0 { if len(data) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: stdin is empty"). return nil, output.ErrValidation("--file: stdin is empty")
WithParam("--file").
WithHint("pipe non-empty file content to stdin")
} }
fd.AddFile(fieldName, bytes.NewReader(data)) fd.AddFile(fieldName, bytes.NewReader(data))
} else { } else {
f, err := fileIO.Open(filePath) f, err := fileIO.Open(filePath)
if err != nil { if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot open file: %s", filePath). return nil, output.ErrValidation("cannot open file: %s", filePath)
WithParam("--file").
WithCause(err)
} }
defer f.Close() defer f.Close()
data, err := io.ReadAll(f) data, err := io.ReadAll(f)
if err != nil { if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read %s: %v", filePath, err). return nil, output.ErrValidation("--file: failed to read %s: %v", filePath, err)
WithParam("--file").
WithCause(err)
} }
fd.AddFile(fieldName, bytes.NewReader(data)) fd.AddFile(fieldName, bytes.NewReader(data))
} }

View File

@@ -5,49 +5,14 @@ package cmdutil
import ( import (
"bytes" "bytes"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio" "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) { func TestParseFileFlag(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -257,7 +222,6 @@ func TestValidateFileFlag(t *testing.T) {
if !strings.Contains(err.Error(), tt.wantErr) { if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error = %q, want containing %q", 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") { if !strings.Contains(err.Error(), "stdin is not available") {
t.Errorf("error = %q, want containing %q", 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) { 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") { if !strings.Contains(err.Error(), "stdin is empty") {
t.Errorf("error = %q, want containing %q", 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) { 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:") { if !strings.Contains(err.Error(), "cannot open file:") {
t.Errorf("error = %q, want containing %q", 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) { t.Run("dataJSON fields added", func(t *testing.T) {

View File

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

View File

@@ -3,40 +3,9 @@
package cmdutil package cmdutil
import ( import "testing"
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
// requireJSONInputValidationError asserts err is a typed *errs.ValidationError
// with subtype invalid_argument, exit code 2 (legacy ErrValidation parity),
// and the offending flag recorded as Param.
func requireJSONInputValidationError(t *testing.T, err error, wantParam string) {
t.Helper()
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if valErr.Param != wantParam {
t.Errorf("param = %q, want %q", valErr.Param, wantParam)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation, legacy parity)", got, output.ExitValidation)
}
if valErr.Cause == nil {
t.Error("expected the underlying parse/resolve error attached as Cause")
}
}
func TestParseOptionalBody(t *testing.T) { func TestParseOptionalBody(t *testing.T) {
fio := &localfileio.LocalFileIO{}
tests := []struct { tests := []struct {
name string name string
method string method string
@@ -51,23 +20,18 @@ func TestParseOptionalBody(t *testing.T) {
{"PATCH valid", "PATCH", `"hello"`, false, false}, {"PATCH valid", "PATCH", `"hello"`, false, false},
{"DELETE valid", "DELETE", `{"id":"1"}`, false, false}, {"DELETE valid", "DELETE", `{"id":"1"}`, false, false},
{"POST invalid json", "POST", `{bad}`, true, true}, {"POST invalid json", "POST", `{bad}`, true, true},
{"POST unreadable @file", "POST", "@/nonexistent/body.json", true, true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := ParseOptionalBody(tt.method, tt.data, nil, fio) got, err := ParseOptionalBody(tt.method, tt.data, nil, nil)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
return return
} }
if tt.wantErr {
requireJSONInputValidationError(t, err, "--data")
return
}
if tt.wantNil && got != nil { if tt.wantNil && got != nil {
t.Errorf("ParseOptionalBody() = %v, want nil", got) t.Errorf("ParseOptionalBody() = %v, want nil", got)
} }
if !tt.wantNil && got == nil { if !tt.wantNil && !tt.wantErr && got == nil {
t.Error("ParseOptionalBody() = nil, want non-nil") t.Error("ParseOptionalBody() = nil, want non-nil")
} }
}) })
@@ -75,7 +39,6 @@ func TestParseOptionalBody(t *testing.T) {
} }
func TestParseJSONMap(t *testing.T) { func TestParseJSONMap(t *testing.T) {
fio := &localfileio.LocalFileIO{}
tests := []struct { tests := []struct {
name string name string
input string input string
@@ -88,20 +51,15 @@ func TestParseJSONMap(t *testing.T) {
{"valid json", `{"a":"1","b":"2"}`, "--params", 2, false}, {"valid json", `{"a":"1","b":"2"}`, "--params", 2, false},
{"invalid json", `{bad}`, "--params", 0, true}, {"invalid json", `{bad}`, "--params", 0, true},
{"json array", `[1,2]`, "--data", 0, true}, {"json array", `[1,2]`, "--data", 0, true},
{"unreadable @file", "@/nonexistent/params.json", "--params", 0, true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, tt.label, nil, fio) got, err := ParseJSONMap(tt.input, tt.label, nil, nil)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
return return
} }
if tt.wantErr { if !tt.wantErr && len(got) != tt.wantLen {
requireJSONInputValidationError(t, err, tt.label)
return
}
if len(got) != tt.wantLen {
t.Errorf("ParseJSONMap() returned map with %d keys, want %d", len(got), tt.wantLen) t.Errorf("ParseJSONMap() returned map with %d keys, want %d", len(got), tt.wantLen)
} }
// A successful parse must yield a non-nil, writable map: callers // A successful parse must yield a non-nil, writable map: callers

View File

@@ -6,8 +6,8 @@ package cmdutil
import ( import (
"strings" "strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
) )
// ParseLangFlag validates and canonicalizes a --lang value, shared by config // ParseLangFlag validates and canonicalizes a --lang value, shared by config
@@ -19,10 +19,9 @@ func ParseLangFlag(raw string) (i18n.Lang, error) {
} }
lang, ok := i18n.Parse(raw) lang, ok := i18n.Parse(raw)
if !ok { if !ok {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, return "", output.ErrValidation(
"invalid --lang %q; valid values: %s", "invalid --lang %q; valid values: %s",
raw, strings.Join(i18n.Codes(), ", ")). raw, strings.Join(i18n.Codes(), ", "))
WithParam("--lang")
} }
return lang, nil return lang, nil
} }

View File

@@ -11,9 +11,9 @@ import (
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/internal/vfs"
) )
@@ -187,12 +187,6 @@ func GetConfigPath() string {
return filepath.Join(GetConfigDir(), "config.json") return filepath.Join(GetConfigDir(), "config.json")
} }
// ErrMalformedConfig marks a config-load failure caused by malformed file
// content (unparseable JSON, structurally empty) rather than a missing or
// unreadable file. Callers classify with errors.Is rather than sniffing the
// message text.
var ErrMalformedConfig = errors.New("malformed config")
// LoadMultiAppConfig loads multi-app config from disk. // LoadMultiAppConfig loads multi-app config from disk.
func LoadMultiAppConfig() (*MultiAppConfig, error) { func LoadMultiAppConfig() (*MultiAppConfig, error) {
data, err := vfs.ReadFile(GetConfigPath()) data, err := vfs.ReadFile(GetConfigPath())
@@ -202,10 +196,10 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) {
var multi MultiAppConfig var multi MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil { if err := json.Unmarshal(data, &multi); err != nil {
return nil, fmt.Errorf("invalid config format: %w: %w", ErrMalformedConfig, err) return nil, fmt.Errorf("invalid config format: %w", err)
} }
if len(multi.Apps) == 0 { if len(multi.Apps) == 0 {
return nil, fmt.Errorf("invalid config format: no apps: %w", ErrMalformedConfig) return nil, fmt.Errorf("invalid config format: no apps")
} }
return &multi, nil return &multi, nil
} }
@@ -243,34 +237,36 @@ func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string)
func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) { func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
app := raw.CurrentAppConfig(profileOverride) app := raw.CurrentAppConfig(profileOverride)
if app == nil { if app == nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found", profileOverride). return nil, &ConfigError{
WithHint("available profiles: %s", formatProfileNames(raw.ProfileNames())) Code: 3,
Type: "config",
Message: fmt.Sprintf("profile %q not found", profileOverride),
Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())),
}
} }
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil { if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync"). return nil, &ConfigError{Code: 3, Type: "config",
WithHint("%s", err.Error()). Message: "appId and appSecret keychain key are out of sync",
WithCause(err) Hint: err.Error()}
} }
secret, err := ResolveSecretInput(app.AppSecret, kc) secret, err := ResolveSecretInput(app.AppSecret, kc)
if err != nil { if err != nil {
if errs.IsTyped(err) { // Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
return nil, err var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return nil, exitErr
} }
subtype := errs.SubtypeNotConfigured return nil, &ConfigError{Code: 3, Type: "config", Message: err.Error()}
if isMalformedConfigError(err) {
subtype = errs.SubtypeInvalidConfig
}
return nil, errs.NewConfigError(subtype, "%s", err.Error()).WithCause(err)
} }
cfg := &CliConfig{ cfg := &CliConfig{
ProfileName: app.ProfileName(), ProfileName: app.ProfileName(),
AppID: app.AppId, AppID: app.AppId,
AppSecret: secret, AppSecret: secret,
Brand: app.Brand, Brand: app.Brand,
Lang: app.Lang,
DefaultAs: app.DefaultAs, DefaultAs: app.DefaultAs,
Lang: app.Lang,
} }
if len(app.Users) > 0 { if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId cfg.UserOpenId = app.Users[0].UserOpenId
@@ -291,8 +287,7 @@ func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) (
return nil, err return nil, err
} }
if cfg.UserOpenId == "" { if cfg.UserOpenId == "" {
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in"). return nil, &ConfigError{Code: 3, Type: "auth", Message: "not logged in", Hint: "run `lark-cli auth login` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login."}
WithHint("run `lark-cli auth login` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.")
} }
return cfg, nil return cfg, nil
} }

View File

@@ -8,7 +8,6 @@ import (
"errors" "errors"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/keychain"
) )
@@ -104,7 +103,7 @@ func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error for mismatched appId and appSecret keychain key") t.Fatal("expected error for mismatched appId and appSecret keychain key")
} }
var cfgErr *errs.ConfigError var cfgErr *ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err) t.Fatalf("expected ConfigError, got %T: %v", err, err)
} }
@@ -133,27 +132,6 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
} }
} }
func TestResolveConfigFromMulti_CarriesLang(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: PlainSecret("my-secret"),
Brand: BrandFeishu,
Lang: "en",
},
},
}
cfg, err := ResolveConfigFromMulti(raw, nil, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Lang != "en" {
t.Errorf("Lang = %q, want %q", cfg.Lang, "en")
}
}
func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T) { func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T) {
// Keychain ref matches appId, so validation passes. // Keychain ref matches appId, so validation passes.
// The subsequent ResolveSecretInput will fail (no real keychain), // The subsequent ResolveSecretInput will fail (no real keychain),
@@ -178,7 +156,7 @@ func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T
t.Fatal("expected error (keychain entry not found), got nil") t.Fatal("expected error (keychain entry not found), got nil")
} }
// The error should come from keychain resolution, NOT from our mismatch check. // The error should come from keychain resolution, NOT from our mismatch check.
var cfgErr *errs.ConfigError var cfgErr *ConfigError
if errors.As(err, &cfgErr) { if errors.As(err, &cfgErr) {
if cfgErr.Message == "appId and appSecret keychain key are out of sync" { if cfgErr.Message == "appId and appSecret keychain key are out of sync" {
t.Fatal("error came from mismatch check, but keys should match") t.Fatal("error came from mismatch check, but keys should match")

22
internal/core/errors.go Normal file
View File

@@ -0,0 +1,22 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import "fmt"
// ConfigError is a structured error from config resolution.
// It carries enough information for main.go to convert it into an output.ExitError.
type ConfigError struct {
Code int // exit code: 3 (config errors share the auth exit code)
Type string // "config" or "auth"
Message string
Hint string
}
func (e *ConfigError) Error() string {
if e.Hint != "" {
return fmt.Sprintf("%s\n %s", e.Message, e.Hint)
}
return e.Message
}

View File

@@ -5,20 +5,10 @@ package core
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"github.com/larksuite/cli/errs"
) )
// isMalformedConfigError reports whether a config load failure indicates a
// malformed file (unparseable / structurally empty) rather than an absent or
// inaccessible one. Malformed files map to the invalid_config subtype so the
// user is told to fix the file instead of re-running init. Detection is by
// ErrMalformedConfig sentinel, not message text.
func isMalformedConfigError(err error) bool {
return errors.Is(err, ErrMalformedConfig)
}
// LoadOrNotConfigured wraps LoadMultiAppConfig with the standard "not yet // LoadOrNotConfigured wraps LoadMultiAppConfig with the standard "not yet
// configured vs. couldn't read" disambiguation that every config-required // configured vs. couldn't read" disambiguation that every config-required
// command should use: // command should use:
@@ -37,15 +27,14 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
return nil, NotConfiguredError() return nil, NotConfiguredError()
} }
// Surface the real cause (parse error, permission denied, etc.) // Surface the real cause (parse error, permission denied, etc.)
// so the user can fix the broken file. A malformed file is // so the user can fix the broken file. Wrapping as ConfigError
// invalid_config; anything else (permission denied, etc.) is // keeps it on the standard structured-envelope path at the root
// not_configured. Both stay on the typed structured-envelope path // command's error sink.
// at the root command's error sink. return nil, &ConfigError{
subtype := errs.SubtypeNotConfigured Code: 3,
if isMalformedConfigError(err) { Type: "config",
subtype = errs.SubtypeInvalidConfig Message: fmt.Sprintf("failed to load config: %v", err),
} }
return nil, errs.NewConfigError(subtype, "failed to load config: %v", err).WithCause(err)
} }
if multi == nil || len(multi.Apps) == 0 { if multi == nil || len(multi.Apps) == 0 {
return nil, NotConfiguredError() return nil, NotConfiguredError()
@@ -81,14 +70,19 @@ const (
func NotConfiguredError() error { func NotConfiguredError() error {
ws := CurrentWorkspace() ws := CurrentWorkspace()
if ws.IsLocal() { if ws.IsLocal() {
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured"). return &ConfigError{
WithHint("%s", localInitHint) Code: 3,
Type: "config",
Message: "not configured",
Hint: localInitHint,
}
}
return &ConfigError{
Code: 3,
Type: ws.Display(),
Message: fmt.Sprintf("%s context detected but lark-cli is not bound to it", ws.Display()),
Hint: agentBindHint,
} }
// Agent workspace: the workspace name appears only in the message, never
// in the wire subtype, which stays not_configured.
return errs.NewConfigError(errs.SubtypeNotConfigured,
"%s context detected but lark-cli is not bound to it", ws.Display()).
WithHint("%s", agentBindHint)
} }
// reconfigureHint returns the workspace-aware "fix it from scratch" hint // reconfigureHint returns the workspace-aware "fix it from scratch" hint
@@ -110,10 +104,17 @@ func reconfigureHint() string {
func NoActiveProfileError() error { func NoActiveProfileError() error {
ws := CurrentWorkspace() ws := CurrentWorkspace()
if ws.IsLocal() { if ws.IsLocal() {
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile"). return &ConfigError{
WithHint("%s", localInitHint) Code: 3,
Type: "config",
Message: "no active profile",
Hint: localInitHint,
}
}
return &ConfigError{
Code: 3,
Type: ws.Display(),
Message: fmt.Sprintf("no active profile in %s workspace", ws.Display()),
Hint: agentBindHint,
} }
return errs.NewConfigError(errs.SubtypeNotConfigured,
"no active profile in %s workspace", ws.Display()).
WithHint("%s", agentBindHint)
} }

View File

@@ -8,8 +8,6 @@ import (
"os" "os"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
) )
// saveAndRestoreWorkspace ensures package-level currentWorkspace is reset // saveAndRestoreWorkspace ensures package-level currentWorkspace is reset
@@ -26,15 +24,12 @@ func TestNotConfiguredError_Local(t *testing.T) {
SetCurrentWorkspace(WorkspaceLocal) SetCurrentWorkspace(WorkspaceLocal)
err := NotConfiguredError() err := NotConfiguredError()
var cfgErr *errs.ConfigError var cfgErr *ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err) t.Fatalf("error type = %T, want *ConfigError", err)
} }
if cfgErr.Category != errs.CategoryConfig || cfgErr.Subtype != errs.SubtypeNotConfigured { if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Errorf("category/subtype = %q/%q, want config/not_configured", cfgErr.Category, cfgErr.Subtype) t.Errorf("unexpected detail: %+v", cfgErr)
}
if cfgErr.Message != "not configured" {
t.Errorf("message = %q, want %q", cfgErr.Message, "not configured")
} }
if !strings.Contains(cfgErr.Hint, "config init --new") { if !strings.Contains(cfgErr.Hint, "config init --new") {
t.Errorf("local hint should suggest config init --new; got %q", cfgErr.Hint) t.Errorf("local hint should suggest config init --new; got %q", cfgErr.Hint)
@@ -49,17 +44,12 @@ func TestNotConfiguredError_OpenClaw(t *testing.T) {
SetCurrentWorkspace(WorkspaceOpenClaw) SetCurrentWorkspace(WorkspaceOpenClaw)
err := NotConfiguredError() err := NotConfiguredError()
var cfgErr *errs.ConfigError var cfgErr *ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err) t.Fatalf("error type = %T, want *ConfigError", err)
} }
// The wire subtype stays not_configured; the workspace name only appears if cfgErr.Type != "openclaw" {
// in the message, never in the typed taxonomy. t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
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)
} }
// Hint must point at --help (read first, confirm with user, then bind), // Hint must point at --help (read first, confirm with user, then bind),
// NOT a directly-executable bind command — binding is policy-laden // NOT a directly-executable bind command — binding is policy-laden
@@ -77,15 +67,12 @@ func TestNotConfiguredError_Hermes(t *testing.T) {
SetCurrentWorkspace(WorkspaceHermes) SetCurrentWorkspace(WorkspaceHermes)
err := NotConfiguredError() err := NotConfiguredError()
var cfgErr *errs.ConfigError var cfgErr *ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err) t.Fatalf("error type = %T, want *ConfigError", err)
} }
if cfgErr.Subtype != errs.SubtypeNotConfigured { if cfgErr.Type != "hermes" {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype) t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
}
if !strings.Contains(cfgErr.Message, "hermes") {
t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message)
} }
if !strings.Contains(cfgErr.Hint, "config bind --help") { if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hermes hint must point to `config bind --help`; got %q", cfgErr.Hint) t.Errorf("hermes hint must point to `config bind --help`; got %q", cfgErr.Hint)
@@ -97,9 +84,9 @@ func TestNoActiveProfileError_Local(t *testing.T) {
SetCurrentWorkspace(WorkspaceLocal) SetCurrentWorkspace(WorkspaceLocal)
err := NoActiveProfileError() err := NoActiveProfileError()
var cfgErr *errs.ConfigError var cfgErr *ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err) t.Fatalf("error type = %T, want *ConfigError", err)
} }
if cfgErr.Message != "no active profile" { if cfgErr.Message != "no active profile" {
t.Errorf("message = %q, want %q", cfgErr.Message, "no active profile") t.Errorf("message = %q, want %q", cfgErr.Message, "no active profile")
@@ -111,9 +98,9 @@ func TestNoActiveProfileError_AgentSuggestsBind(t *testing.T) {
SetCurrentWorkspace(WorkspaceOpenClaw) SetCurrentWorkspace(WorkspaceOpenClaw)
err := NoActiveProfileError() err := NoActiveProfileError()
var cfgErr *errs.ConfigError var cfgErr *ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err) t.Fatalf("error type = %T, want *ConfigError", err)
} }
if !strings.Contains(cfgErr.Hint, "config bind --help") { if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint) t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
@@ -149,12 +136,9 @@ func TestLoadOrNotConfigured_FileMissing_ReturnsNotConfigured(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
var cfgErr *errs.ConfigError var cfgErr *ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err) t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
} }
if cfgErr.Message != "not configured" { if cfgErr.Message != "not configured" {
t.Errorf("message = %q, want \"not configured\"", cfgErr.Message) t.Errorf("message = %q, want \"not configured\"", cfgErr.Message)
@@ -180,13 +164,9 @@ func TestLoadOrNotConfigured_CorruptFile_PreservesCause(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error for corrupt config") t.Fatal("expected error for corrupt config")
} }
var cfgErr *errs.ConfigError var cfgErr *ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err) t.Fatalf("error type = %T, want *ConfigError", err)
}
// A malformed file maps to invalid_config, not not_configured.
if cfgErr.Subtype != errs.SubtypeInvalidConfig {
t.Errorf("subtype = %q, want invalid_config", cfgErr.Subtype)
} }
if !strings.Contains(cfgErr.Message, "failed to load config") { if !strings.Contains(cfgErr.Message, "failed to load config") {
t.Errorf("corrupt-file message must say 'failed to load config'; got %q", cfgErr.Message) t.Errorf("corrupt-file message must say 'failed to load config'; got %q", cfgErr.Message)
@@ -198,8 +178,4 @@ func TestLoadOrNotConfigured_CorruptFile_PreservesCause(t *testing.T) {
if strings.Contains(cfgErr.Hint, "config init") || strings.Contains(cfgErr.Hint, "config bind") { if strings.Contains(cfgErr.Hint, "config init") || strings.Contains(cfgErr.Hint, "config bind") {
t.Errorf("corrupt-file hint must not redirect to init/bind; got %q", cfgErr.Hint) t.Errorf("corrupt-file hint must not redirect to init/bind; got %q", cfgErr.Hint)
} }
// The underlying parse failure stays reachable through the unwrap chain.
if cfgErr.Cause == nil {
t.Error("Cause must wrap the underlying load error for errors.Is/Unwrap")
}
} }

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package errcompat provides boundary helpers that bridge legacy error types
// to the typed errs/ taxonomy. These helpers run at the dispatcher boundary
// (cmd/root.go.handleRootError) before the typed envelope writer, converting
// pre-typed-taxonomy errors (*core.ConfigError, *internalauth.NeedAuthorizationError)
// into typed *errs.* errors while preserving the original error in the Cause
// chain so existing `errors.As` callers continue to match.
package errcompat
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// PromoteConfigError converts a legacy *core.ConfigError into the matching
// typed errs.*Error based on cfgErr.Type. Called from cmd/root.go.handleRootError
// before the typed envelope writer. The original *core.ConfigError is preserved
// in the Cause chain so external `errors.As(&core.ConfigError{})` callers
// (cmd/auth/list.go, cmd/doctor/doctor.go, etc.) still match.
func PromoteConfigError(cfgErr *core.ConfigError) error {
if cfgErr == nil {
return nil
}
switch cfgErr.Type {
case "auth":
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "%s", cfgErr.Message).
WithHint("%s", cfgErr.Hint).
WithCause(cfgErr)
case "config":
subtype := errs.SubtypeNotConfigured
lower := strings.ToLower(cfgErr.Message)
if strings.Contains(lower, "parse") || strings.Contains(lower, "invalid") {
subtype = errs.SubtypeInvalidConfig
}
return errs.NewConfigError(subtype, "%s", cfgErr.Message).
WithHint("%s", cfgErr.Hint).
WithCause(cfgErr)
default:
// dynamic Type (e.g. workspace name like "bind"/"hermes"/"openclaw") → NotConfigured
return errs.NewConfigError(errs.SubtypeNotConfigured, "%s", cfgErr.Message).
WithHint("%s", cfgErr.Hint).
WithCause(cfgErr)
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errcompat
import (
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
)
// PromoteAuthError converts a legacy *internalauth.NeedAuthorizationError into
// *errs.AuthenticationError{Subtype: TokenMissing}. The Message field MUST
// contain "need_user_authorization" so the marker invariant guardrail in
// cmd/root_test.go and internal/auth/errors_test.go still holds.
//
// Hint mirrors newTokenMissingError in internal/client/client.go so both
// token-missing surfaces converge on the same recovery vocabulary. cmd's
// applyNeedAuthorizationHint appends per-command scopes onto this Hint with
// a "\n" join, so the action prompt is preserved even when scopes are added.
//
// Called from cmd/root.go.handleRootError when errors.As matches
// *NeedAuthorizationError, before WriteTypedErrorEnvelope.
func PromoteAuthError(err *internalauth.NeedAuthorizationError) error {
if err == nil {
return nil
}
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"need_user_authorization (user: %s)", err.UserOpenId).
WithUserOpenID(err.UserOpenId).
WithHint("run: lark-cli auth login to re-authorize").
WithCause(err)
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errcompat
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
)
func TestPromoteAuthError_PromotesNeedAuthorizationError(t *testing.T) {
needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
got := PromoteAuthError(needAuth)
var authErr *errs.AuthenticationError
if !errors.As(got, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
}
if authErr.Subtype != errs.SubtypeTokenMissing {
t.Errorf("subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
}
// Cause chain must preserve original *NeedAuthorizationError so legacy
// consumers (auth.IsNeedUserAuthorizationError + errors.As pattern in
// internal/auth/errors.go:42) still match.
var preserved *internalauth.NeedAuthorizationError
if !errors.As(got, &preserved) {
t.Error("Unwrap chain lost *NeedAuthorizationError — breaks auth.IsNeedUserAuthorizationError consumer")
}
}
func TestPromoteAuthError_PreservesNeedUserAuthorizationMarker(t *testing.T) {
needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
got := PromoteAuthError(needAuth)
if !strings.Contains(got.Error(), "need_user_authorization") {
t.Errorf("Message must contain need_user_authorization marker, got: %q", got.Error())
}
}
func TestPromoteAuthError_PreservesUserOpenID(t *testing.T) {
needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_test_open_id"}
got := PromoteAuthError(needAuth)
var authErr *errs.AuthenticationError
if !errors.As(got, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
}
if authErr.UserOpenID != "u_test_open_id" {
t.Errorf("UserOpenID = %q, want preserved", authErr.UserOpenID)
}
}
// TestPromoteAuthError_CarriesAuthLoginHint pins that the recovery action
// prompt is attached at promotion time — without this Hint, downstream
// consumers see authentication/token_missing but no "run: lark-cli auth login"
// guidance, mirroring the pre-typed UX failure when NeedAuthorizationError
// surfaced as a bare network error. cmd's applyNeedAuthorizationHint relies
// on this Hint being non-empty so scope enrichment appends instead of
// overwrites the recovery prompt.
func TestPromoteAuthError_CarriesAuthLoginHint(t *testing.T) {
got := PromoteAuthError(&internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"})
var authErr *errs.AuthenticationError
if !errors.As(got, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
}
if !strings.Contains(authErr.Hint, "lark-cli auth login") {
t.Errorf("Hint must guide user to re-authorize, got: %q", authErr.Hint)
}
}
func TestPromoteAuthError_Nil_ReturnsNil(t *testing.T) {
if got := PromoteAuthError(nil); got != nil {
t.Errorf("nil input should return nil, got %v", got)
}
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errcompat_test
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errcompat"
)
func TestPromoteConfigError_TypeAuth_PromotesToAuthenticationError(t *testing.T) {
cfg := &core.ConfigError{
Type: "auth",
Code: 3,
Message: "not logged in",
Hint: "run: lark-cli auth login",
}
got := errcompat.PromoteConfigError(cfg)
var authErr *errs.AuthenticationError
if !errors.As(got, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
}
if authErr.Subtype != errs.SubtypeTokenMissing {
t.Errorf("subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
}
// Cause chain must preserve original *core.ConfigError for errors.As compat.
var cfgPreserved *core.ConfigError
if !errors.As(got, &cfgPreserved) {
t.Error("Unwrap chain lost *core.ConfigError — breaks cmd/auth/list.go consumer")
}
}
func TestPromoteConfigError_TypeConfig_PromotesToConfigError(t *testing.T) {
cases := []struct {
name string
msg string
wantSubtype errs.Subtype
}{
{"not_configured", "not configured", errs.SubtypeNotConfigured},
{"invalid_config_parse", "failed to parse config", errs.SubtypeInvalidConfig},
{"invalid_config_keyword", "invalid config file", errs.SubtypeInvalidConfig},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := &core.ConfigError{Type: "config", Code: 3, Message: tc.msg}
got := errcompat.PromoteConfigError(cfg)
var ce *errs.ConfigError
if !errors.As(got, &ce) {
t.Fatalf("expected *errs.ConfigError, got %T", got)
}
if ce.Subtype != tc.wantSubtype {
t.Errorf("subtype = %v, want %v", ce.Subtype, tc.wantSubtype)
}
})
}
}
func TestPromoteConfigError_TypeDynamic_PromotesToConfigError(t *testing.T) {
for _, wsName := range []string{"openclaw", "hermes", "bind"} {
t.Run(wsName, func(t *testing.T) {
cfg := &core.ConfigError{Type: wsName, Code: 3, Message: "not configured"}
got := errcompat.PromoteConfigError(cfg)
var ce *errs.ConfigError
if !errors.As(got, &ce) {
t.Fatalf("expected *errs.ConfigError, got %T", got)
}
if ce.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %v, want %v", ce.Subtype, errs.SubtypeNotConfigured)
}
})
}
}
func TestPromoteConfigError_Nil_ReturnsNil(t *testing.T) {
if got := errcompat.PromoteConfigError(nil); got != nil {
t.Errorf("nil input should return nil, got %v", got)
}
}
func TestPromoteConfigError_PreservesMessageHint(t *testing.T) {
cfg := &core.ConfigError{
Type: "auth",
Message: "session expired (user: u_xxx)",
Hint: "re-authenticate",
}
got := errcompat.PromoteConfigError(cfg)
if !strings.Contains(got.Error(), "session expired") {
t.Errorf("message lost in promotion: %v", got)
}
var authErr *errs.AuthenticationError
if !errors.As(got, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
}
if authErr.Hint != "re-authenticate" {
t.Errorf("hint = %q, want preserved", authErr.Hint)
}
}

View File

@@ -269,26 +269,8 @@ func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.H
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID) bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID)
bc.SetLogger(b.logger) bc.SetLogger(b.logger)
// SingleConsumer EventKeys allow only one consumer per SubscriptionID: reject extras at handshake. // Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
exclusive := false firstForKey := b.hub.RegisterAndIsFirst(bc)
if def, ok := event.Lookup(hello.EventKey); ok {
exclusive = def.SingleConsumer
}
var firstForKey bool
if exclusive {
ok, reason := b.hub.TryRegisterExclusive(bc)
if !ok {
if err := bc.writeFrame(protocol.NewHelloAckRejected("v1", reason)); err != nil {
b.logger.Printf("WARN: reject hello_ack write to pid=%d key=%q failed: %v", hello.PID, hello.EventKey, err)
}
bc.Close()
return
}
firstForKey = true
} else {
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
firstForKey = b.hub.RegisterAndIsFirst(bc)
}
bc.SetCheckLastForKey(func(scope string) bool { bc.SetCheckLastForKey(func(scope string) bool {
return b.hub.AcquireCleanupLock(scope) return b.hub.AcquireCleanupLock(scope)

View File

@@ -5,15 +5,12 @@ package bus
import ( import (
"bufio" "bufio"
"bytes"
"io" "io"
"log" "log"
"net" "net"
"strings"
"testing" "testing"
"time" "time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol" "github.com/larksuite/cli/internal/event/protocol"
) )
@@ -197,60 +194,3 @@ func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) {
t.Fatal("HelloAck was empty") t.Fatal("HelloAck was empty")
} }
} }
// TestHandleHello_SingleConsumerRejectsSecond: a SingleConsumer EventKey accepts
// the first consumer and rejects the second for the same SubscriptionID.
func TestHandleHello_SingleConsumerRejectsSecond(t *testing.T) {
const key = "test.handlehello.exclusive"
event.RegisterKey(event.KeyDefinition{
Key: key,
EventType: key,
SingleConsumer: true,
Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
})
defer event.UnregisterKeyForTest(key)
logger := log.New(io.Discard, "", 0)
hub := NewHub()
b := &Bus{
hub: hub,
logger: logger,
conns: make(map[*Conn]struct{}),
idleTimer: time.NewTimer(30 * time.Second),
shutdownCh: make(chan struct{}, 1),
}
readAck := func(t *testing.T, pid int) *protocol.HelloAck {
t.Helper()
server, client := net.Pipe()
t.Cleanup(func() { server.Close(); client.Close() })
hello := &protocol.Hello{PID: pid, EventKey: key, EventTypes: []string{key}}
go b.handleHello(server, bufio.NewReader(server), hello)
line, err := protocol.ReadFrame(bufio.NewReader(client))
if err != nil {
t.Fatalf("read ack (pid %d): %v", pid, err)
}
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
if err != nil {
t.Fatalf("decode ack (pid %d): %v", pid, err)
}
ack, ok := msg.(*protocol.HelloAck)
if !ok {
t.Fatalf("got %T, want *HelloAck", msg)
}
return ack
}
ack1 := readAck(t, 100)
if ack1.Rejected {
t.Fatalf("first consumer should be accepted, got rejected: %q", ack1.RejectReason)
}
ack2 := readAck(t, 200)
if !ack2.Rejected {
t.Fatal("second consumer should be rejected")
}
if !strings.Contains(ack2.RejectReason, "already running") {
t.Errorf("reject reason = %q, want mention of 'already running'", ack2.RejectReason)
}
}

View File

@@ -6,34 +6,13 @@ package bus
import ( import (
"fmt" "fmt"
"log" "log"
"os"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol" "github.com/larksuite/cli/internal/event/protocol"
) )
// exclusiveCleanupWaitTimeout bounds how long TryRegisterExclusive waits for an
// in-progress cleanup of the same subscription before rejecting, so a stuck
// cleanup can never wedge new consumers forever. Kept below the consumer's
// hello_ack deadline (consume.helloAckTimeout = 5s) so the reject still reaches
// the consumer as a clean failed_precondition instead of a handshake timeout.
// Override with LARKSUITE_CLI_EVENT_EXCLUSIVE_WAIT_TIMEOUT (a Go duration such as
// "2s"); values at or above the 5s handshake deadline are not recommended.
var exclusiveCleanupWaitTimeout = resolveExclusiveCleanupWaitTimeout()
func resolveExclusiveCleanupWaitTimeout() time.Duration {
const def = 3 * time.Second
if v := os.Getenv("LARKSUITE_CLI_EVENT_EXCLUSIVE_WAIT_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
return def
}
// Subscriber is the interface a connection must satisfy for Hub registration. // Subscriber is the interface a connection must satisfy for Hub registration.
type Subscriber interface { type Subscriber interface {
EventKey() string EventKey() string
@@ -145,63 +124,6 @@ func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
} }
} }
// TryRegisterExclusive registers s only when no subscriber holds s.SubscriptionID()
// and any in-progress cleanup for that subscription finishes within
// exclusiveCleanupWaitTimeout. On failure it returns (false, reason): either a
// duplicate consumer already holds the subscription, or the cleanup did not
// finish in time — the timeout guarantees a stuck cleanup can never wedge new
// consumers forever. reason is "" on success. Mirrors RegisterAndIsFirst's wait
// on in-progress cleanup, but bounded.
func (h *Hub) TryRegisterExclusive(s Subscriber) (bool, string) {
sid := s.SubscriptionID()
deadline := time.Now().Add(exclusiveCleanupWaitTimeout)
for {
h.mu.Lock()
ch, locked := h.cleanupInProgress[sid]
if locked {
h.mu.Unlock()
remaining := time.Until(deadline)
if remaining <= 0 {
return false, "timed out waiting for the previous consumer's cleanup to finish; retry shortly"
}
timer := time.NewTimer(remaining)
select {
case <-ch:
// Stop+drain so a timer that fired concurrently with Stop isn't left on .C.
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
continue
case <-timer.C:
return false, "timed out waiting for the previous consumer's cleanup to finish; retry shortly"
}
}
if h.subCounts[sid] != 0 {
pid := h.existingPIDForSubscriptionLocked(sid)
h.mu.Unlock()
return false, fmt.Sprintf("another consumer (pid %d) is already running for this subscription", pid)
}
h.subscribers[s] = struct{}{}
h.subCounts[sid]++
h.mu.Unlock()
return true, ""
}
}
// existingPIDForSubscriptionLocked returns the PID of one subscriber for sid.
// Caller must hold h.mu.
func (h *Hub) existingPIDForSubscriptionLocked(sid string) int {
for sub := range h.subscribers {
if sub.SubscriptionID() == sid {
return sub.PID()
}
}
return 0
}
// Publish fans out a RawEvent to all matching subscribers (non-blocking). // Publish fans out a RawEvent to all matching subscribers (non-blocking).
// //
// A fresh *protocol.Event is allocated per subscriber so each consumer sees // A fresh *protocol.Event is allocated per subscriber so each consumer sees

View File

@@ -6,7 +6,6 @@ package bus
import ( import (
"encoding/json" "encoding/json"
"net" "net"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -356,69 +355,3 @@ func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) {
t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice") t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice")
} }
} }
func TestHub_TryRegisterExclusive(t *testing.T) {
h := NewHub()
first := newTestConn("k.exclusive", []string{"k.exclusive"})
first.pid = 100
ok, _ := h.TryRegisterExclusive(first)
if !ok {
t.Fatal("first exclusive register should succeed")
}
second := newTestConn("k.exclusive", []string{"k.exclusive"})
second.pid = 200
ok, reason := h.TryRegisterExclusive(second)
if ok {
t.Error("second exclusive register should be rejected")
}
if !strings.Contains(reason, "pid 100") {
t.Errorf("reject reason = %q, want it to name existing pid 100", reason)
}
if got := h.SubCount("k.exclusive"); got != 1 {
t.Errorf("SubCount = %d, want 1 (second not registered)", got)
}
}
func TestHub_TryRegisterExclusive_CleanupWaitTimeout(t *testing.T) {
// A cleanup lock that never releases must not wedge a new exclusive consumer
// forever — TryRegisterExclusive bounds the wait and rejects with a timeout reason.
saved := exclusiveCleanupWaitTimeout
exclusiveCleanupWaitTimeout = 20 * time.Millisecond
defer func() { exclusiveCleanupWaitTimeout = saved }()
h := NewHub()
first := newTestConn("k.timeout", []string{"k.timeout"})
if ok, _ := h.TryRegisterExclusive(first); !ok {
t.Fatal("first exclusive register should succeed")
}
// Hold the cleanup lock and never release it.
if !h.AcquireCleanupLock("k.timeout") {
t.Fatal("AcquireCleanupLock should succeed for the sole subscriber")
}
start := time.Now()
second := newTestConn("k.timeout", []string{"k.timeout"})
ok, reason := h.TryRegisterExclusive(second)
if ok {
t.Error("second exclusive register should be rejected on cleanup-wait timeout")
}
if !strings.Contains(reason, "timed out") {
t.Errorf("reject reason = %q, want a timeout reason", reason)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("wait took %v, want bounded by the ~20ms timeout (no deadlock)", elapsed)
}
}
func TestHub_TryRegisterExclusive_DistinctSubscriptions(t *testing.T) {
h := NewHub()
a := newTestConn("k.a", []string{"k.a"})
b := newTestConn("k.b", []string{"k.b"})
if ok, _ := h.TryRegisterExclusive(a); !ok {
t.Fatal("register a failed")
}
if ok, _ := h.TryRegisterExclusive(b); !ok {
t.Error("distinct subscription b should register")
}
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/transport" "github.com/larksuite/cli/internal/event/transport"
) )
@@ -103,9 +102,6 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
return errs.NewInternalError(errs.SubtypeUnknown, return errs.NewInternalError(errs.SubtypeUnknown,
"event bus handshake failed: %s", err).WithCause(err) "event bus handshake failed: %s", err).WithCause(err)
} }
if rejErr := rejectionError(ack, opts.EventKey); rejErr != nil {
return rejErr
}
var cleanup func() error var cleanup func() error
if ack.FirstForKey && keyDef.PreConsume != nil { if ack.FirstForKey && keyDef.PreConsume != nil {
@@ -175,17 +171,6 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted) return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted)
} }
// rejectionError converts a rejected hello_ack into a structured precondition
// error; returns nil when the ack is absent or not a rejection.
func rejectionError(ack *protocol.HelloAck, eventKey string) error {
if ack == nil || !ack.Rejected {
return nil
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"cannot start consumer: %s", ack.RejectReason).
WithHint("EventKey %s allows only one consumer; run `lark-cli event status` to find the running one, then stop it before retrying", eventKey)
}
func truncateDuration(d time.Duration) time.Duration { func truncateDuration(d time.Duration) time.Duration {
return d.Truncate(time.Second) return d.Truncate(time.Second)
} }

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