Compare commits

..

1 Commits

Author SHA1 Message Date
江国洲
2424f6dfc2 docs: add drive knowledge asset workflow guidance
Change-Id: I2431a436e79d5a0e62501fb51aad4bde83f393b8
2026-05-26 16:39:03 +08:00
1513 changed files with 27174 additions and 213107 deletions

30
.github/CODEOWNERS vendored
View File

@@ -1,30 +0,0 @@
/internal/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1
/skills/lark-approval/
/skills/lark-apps/
/skills/lark-attendance/
/skills/lark-base/
/skills/lark-calendar/
/skills/lark-contact/
/skills/lark-doc/
/skills/lark-drive/
/skills/lark-event/
/skills/lark-im/
/skills/lark-mail/
/skills/lark-markdown/
/skills/lark-minutes/
/skills/lark-okr/
/skills/lark-openapi-explorer/
/skills/lark-shared/
/skills/lark-sheets/
/skills/lark-skill-maker/
/skills/lark-slides/
/skills/lark-task/
/skills/lark-vc/
/skills/lark-vc-agent/
/skills/lark-whiteboard/
/skills/lark-wiki/
/skills/lark-workflow-meeting-summary/
/skills/lark-workflow-standup-report/

View File

@@ -9,7 +9,7 @@
## Test Plan ## Test Plan
<!-- Describe how this change was verified. --> <!-- Describe how this change was verified. -->
- [ ] Unit tests pass - [ ] Unit tests pass
- [ ] Manual local verification confirms the `lark-cli <domain> <command>` flow works as expected - [ ] Manual local verification confirms the `lark xxx` command works as expected
## Related Issues ## Related Issues
<!-- Link related issues. Use Closes/Fixes to close them automatically. --> <!-- Link related issues. Use Closes/Fixes to close them automatically. -->

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,8 @@ 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)
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Run CLI deterministic gate
run: make quality-gate
- name: Upload quality gate facts
if: ${{ always() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: quality-gate-facts-${{ github.event.pull_request.base.sha }}-${{ github.event.pull_request.head.sha }}
path: .tmp/quality-gate/facts.json
if-no-files-found: error
retention-days: 7
coverage: coverage:
needs: fast-gate needs: fast-gate
@@ -138,7 +101,6 @@ jobs:
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/') 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 +182,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 +203,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 +252,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 +289,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 +301,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 +316,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 });

4
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Build output # Build output
/lark-cli* /lark-cli
.cache/ .cache/
dist/ dist/
bin/ bin/
@@ -35,8 +35,6 @@ tests/mail/reports/
# Generated / test artifacts # Generated / test artifacts
.hammer/ .hammer/
.lark-slides/ .lark-slides/
/notes/
/minutes/
internal/registry/meta_data.json internal/registry/meta_data.json
cmd/api/download.bin cmd/api/download.bin
app.log app.log

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,49 +49,18 @@ linters:
- gocritic - gocritic
- depguard - depguard
- forbidigo - forbidigo
- errorlint # tests legitimately do identity (==) and concrete type-assert checks - path-except: (shortcuts/|internal/)
# forbidigo runs repo-wide (minus the boundaries below) so errs-no-bare-wrap
# has no gap. The framework bans (os/vfs, raw HTTP, fmt.Print, filepath,
# log) stay scoped to shortcuts/ + internal/ + config/auth/service via the
# next rule; elsewhere only errs-no-bare-wrap fires.
- path-except: (shortcuts/|internal/|cmd/|events/)
linters:
- forbidigo
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/)
text: (vfs|IOStreams|ctx\.Out|shortcuts-no-raw-http|filepath functions|os\.Exit|structured error return)
linters: linters:
- forbidigo - forbidigo
- path: internal/vfs/ - path: internal/vfs/
linters: linters:
- forbidigo - forbidigo
# internal/gen build-time generators (standalone `package main` run via # The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
# go:generate) are not shortcut runtime code — no ctx/runtime/framework — # internal/ legitimately wraps raw HTTP for the client / credential layer.
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
# impossible here: a structured error return needs os.Exit (also banned),
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# internal/qualitygate/cmd contains standalone CI tools. Their main
# entrypoints legitimately own process exit codes and stdio, matching the
# old tools/ layout before these packages moved under internal/.
- path: internal/qualitygate/cmd/[^/]+/main\.go$
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
- 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
# structural prefix, so any future business domain or command is covered
# without editing an allowlist. Genuine intermediate wraps inside these
# paths use //nolint:forbidigo with a reason.
- path-except: (cmd/|shortcuts/|events/)
text: errs-no-bare-wrap
linters:
- forbidigo
settings: settings:
depguard: depguard:
@@ -110,12 +79,6 @@ 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:
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
wrap a cause with .WithCause(err). Genuine intermediate wraps:
//nolint:forbidigo with a reason.
# ── http: shortcuts must not construct raw HTTP requests ── # ── http: shortcuts must not construct raw HTTP requests ──
# Bans request / client construction; constants (http.MethodPost, # Bans request / client construction; constants (http.MethodPost,
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are # http.StatusOK) and pure helpers (http.StatusText, http.Header) are

View File

@@ -17,7 +17,6 @@ builds:
goarch: goarch:
- amd64 - amd64
- arm64 - arm64
- riscv64
archives: archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}" - name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"

View File

@@ -11,7 +11,7 @@
```bash ```bash
make build # Build (runs fetch_meta first) make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64) make unit-test # Required before PR (runs with -race)
make test # Full: vet + unit + integration make test # Full: vet + unit + integration
``` ```
@@ -75,31 +75,7 @@ The one rule to internalize: **every error message you write will be parsed by a
### Structured errors in commands ### Structured errors in commands
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`. `RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
Picking a constructor:
| Failure | Constructor |
|---------|-------------|
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
Signatures that are easy to guess wrong:
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }``ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
### stdout is data, stderr is everything else ### stdout is data, stderr is everything else

View File

@@ -2,382 +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
### Features
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
- **drive**: Support `original_creator_ids` filter in search (#1046)
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
### Bug Fixes
- **doc**: Fix docs fetch and update ergonomics (#1466)
- **vfs**: Reject blank local paths (#1460)
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
- **event**: Clarify remote bus blocker recovery (#1454)
### Refactor
- Converge command pipelines onto a typed metadata model + catalog (#1191)
### Documentation
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
- **doc**: Clarify lark-doc create title guidance (#1474)
- **skills**: Add rename prompt for import without `--name` (#1461)
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
## [v1.0.53] - 2026-06-12
### Features
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
- **auth**: Add `--json` flag support to auth subcommands (#1431)
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
### Bug Fixes
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
### Documentation
- **skills**: Optimize whiteboard skill (#1371)
- **skills**: Optimize okr skill (#1368)
## [v1.0.52] - 2026-06-11
### Features
- **events**: Per-resource subscription identity + Match hook (#1185)
- **apps**: Emit typed error envelopes across the apps domain (#1288)
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
- **im**: Add `--chat-modes` filter to chat search (#1317)
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
### Bug Fixes
- **apps**: Support git credential dry-run (#1390)
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
- **build**: Make `-race` flag arch-conditional to support riscv64
### Documentation
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
- **skills**: Optimize lark-drive skill routing (#1284)
- **skills**: Expand cite user guidance and fix typos (#1394)
## [v1.0.51] - 2026-06-10
### Features
- **apps**: Support multi dev modes (#1175)
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
- **base**: Configure initial base table schema (#1377)
- **vc**: Add recording event support (#1369)
- **minutes**: Replace words for transcript (#1372)
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
- **slides**: Emit typed error envelopes across the slides domain (#1349)
### Documentation
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
## [v1.0.50] - 2026-06-09
### Features
- **doc**: Emit typed error envelopes across the doc domain (#1346)
- **event**: Emit typed error envelopes across the event domain (#1289)
- **contact**: Emit typed error envelopes across the contact domain (#1287)
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
- **cli**: Adjust agent timeout hint output conditions (#1328)
### Bug Fixes
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
- **slides**: Build create URL locally instead of drive metas call (#1329)
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
### Documentation
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
- **doc**: Document `<folder-manager>` resource block (#1168)
- **drive**: Add drive comment location guidance (#1258)
## [v1.0.49] - 2026-06-08
### Features
- **events**: Add whiteboard event domain with per-board subscription (#1265)
- **im**: Support feed group (#1102)
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
- **im**: Format feed group error handling (#1308)
- **im**: Return typed error envelopes across the im domain (#1230)
- **base**: Emit typed error envelopes across the base domain (#1248)
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
- **task**: Emit typed error envelopes across the task domain (#1231)
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
- **markdown**: Harden create upload failures (#1325)
- **drive**: Harden inspect shortcut failures (#1324)
- **slides**: Add IconPark lookup for Lark slides (#1123)
- **doc**: Remove docs v1 API (#1291)
- **cli**: Add `skills` command to read embedded skill content (#1318)
- **cli**: Fetch official skills index (#1301)
- **shared**: Document relative-path-only file arguments (#1319)
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
### Bug Fixes
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
- **drive**: Use docs secure label read scope (#1281)
### Documentation
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
- **skills**: Tighten drive and markdown guardrails (#1326)
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
- **markdown**: Add markdown domain template (#1293)
- **markdown**: Improve lark-markdown skill guidance (#1279)
- **doc**: Improve lark-doc skill guidance (#1283)
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
## [v1.0.48] - 2026-06-04
### Features
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
- **contact**: Add contact skill domain guidance (#1144)
### Bug Fixes
- **skills**: Use JSON skills list during update (#1251)
### Documentation
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
- **vc-agent**: Require explicit leave request (#1260)
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
## [v1.0.47] - 2026-06-03
### Features
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
- **base**: Add base block shortcuts (#1044)
- **im**: Complete card message format (#1198)
- **im**: Improve markdown guidance for messages (#1237)
- **vc**: Forward invite call-id on meeting join (#1243)
- **drive**: Emit typed error envelopes across the drive domain (#1205)
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
- **wiki**: Support `appid` member type (#1235)
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
- **config**: Validate credentials after `config init` (#1151)
### Bug Fixes
- **skills**: Recover empty fallback for skills to update (#1233)
## [v1.0.46] - 2026-06-02
### Features
- **im**: Add card message format support (#1218)
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
- **agent**: Increase agent trace max length to 1024 (#1211)
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
### Bug Fixes
- **cli**: Remove FLAGS section from root `--help` (#1226)
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
### Refactor
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
### Documentation
- **base**: Optimize base skill references (#1171)
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
## [v1.0.45] - 2026-06-01
### Features
- **errors**: Add typed envelope contract for auth-domain errors (#1135)
- **platform**: Support multiple policy rules per plugin (#1182)
### Bug Fixes
- **vc**: Add domain boundaries and enrich `+notes` (#1172)
- **whiteboard**: Fix whiteboard skill (#1180)
### Refactor
- **auth**: Update login hint and split-flow docs (#1201)
## [v1.0.44] - 2026-05-29
### Features
- **base**: Add dashboard block data shortcut and workflow docs (#1067)
- **im**: Support `--types` flag for listing p2p single chats in `chat-list` (#1077)
- **agent**: Add agent header support (#1158)
### Bug Fixes
- **im**: Correct 64-bit MP4 box size handling to prevent panic on crafted media (#1165)
- **install**: Detect curl version before using `--ssl-revoke-best-effort` (#1124)
- **vc**: Correct `--minute-token` to `--minute-tokens` in recording reference (#1170)
- **whiteboard**: Fix whiteboard skill (#1166)
## [v1.0.43] - 2026-05-28
### Features
- **event**: Support `note` generated event (#1159)
- **config**: Decouple `--lang` preference from TUI display language (#1132)
- **mail**: Add HTML lint library with Larksuite-native autofix for `lark-mail` (#1019)
### Bug Fixes
- **config**: Propagate `Lang` across credential boundary; respect `CurrentApp` in priorLang (#1157)
- **config**: Allow lark-channel bind source override (#1154)
- **im**: Clarify `messages-send` dry-run chat membership (#1150)
- **base**: Include `log_id` in attachment media errors (#1133)
### Performance
- **im**: Parallelize reactions, thread_replies, and merge_forward fetches (#1146)
### Documentation
- **im**: Update IM skill urgent APIs (#1153)
## [v1.0.42] - 2026-05-27
### Features
- **mail**: Add `+draft-send` shortcut for batch draft sending (#1017)
- **im**: Enrich messages with reactions and output `update_time` (#1095)
- **schema**: Output JSON spec envelope for all API commands (#1048)
- **event**: Support `vc` / `note` / `minute` events (#1113)
- **drive**: Add secure label shortcuts (#985)
- **affordance**: Use description and command in affordance example schema (#1126)
### Bug Fixes
- **docs**: Remove unsupported `fetch` text format (#1109)
### Refactor
- **auth**: Drop duplicate top-level user fields in `status` (#1128)
### Documentation
- **doc**: Document block anchor URLs in `lark-doc` skill (#1120)
- **whiteboard**: Improve SVG/Mermaid instructions (#1097)
## [v1.0.41] - 2026-05-26
### Features
- **minutes**: Add minutes edit shortcuts (#1036)
- **minutes**: Get minutes keywords (#1079)
- **slides**: Support importing pptx as slides (#1068)
- **config**: Add `keychain-downgrade` subcommand (macOS) (#1085)
- **errors**: Add structured CLI error contract (#984)
- **apps**: Replace `+html-publish` cwd hard-reject with credential-file scan (#1072)
### Bug Fixes
- **drive**: Support doubao drive inspect URL variants (#1106)
- **skills**: Sync skills incrementally during update (#1042)
- **apps**: Read app object from `data.app` for `+create` and `+update` (#1087)
- **common**: Escape special chars in multipart form filenames (#1037)
- **auth**: Remove fenced code block guidance from auth URL output hints (#1088)
### Documentation
- **skills**: Fix agent routing for doubao.com URLs (#1082)
- **task**: Require `--complete=false` for pending standup summaries (#1101)
- **base**: Document UI-only field settings (#1078)
- **contributing**: Clarify contributor guidance (#1096)
## [v1.0.40] - 2026-05-25 ## [v1.0.40] - 2026-05-25
### Features ### Features
@@ -1236,23 +860,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.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40 [v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39 [v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38 [v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38

View File

@@ -5,24 +5,10 @@ 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
# The repository's Go 1.23 CI toolchain does not support -race on riscv64. .PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
# over `go env GOARCH`, because command-line make variables are not visible to
# $(shell ...).
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
all: test all: test
@@ -46,15 +32,9 @@ 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 -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/... ./cmd/... ./internal/... ./shortcuts/... ./extension/...
# examples-build keeps the shipped plugin-SDK examples compilable. If this # examples-build keeps the shipped plugin-SDK examples compilable. If this
@@ -66,30 +46,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

@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances | | ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. | | 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) | | 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope | | 🔗 Apps | Develop, deploy HTML, web pages and applications |
## Installation & Quick Start ## Installation & Quick Start
@@ -279,8 +279,6 @@ Community contributions are welcome! If you find a bug or have feature suggestio
For major changes, we recommend discussing with us first via an Issue. For major changes, we recommend discussing with us first via an Issue.
Before opening a PR, see [AGENTS.md](./AGENTS.md) for the local build, test, and PR checklist used by contributors and AI agents.
## License ## License
This project is licensed under the **MIT License**. This project is licensed under the **MIT License**.

View File

@@ -41,7 +41,7 @@
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 | | ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 | | 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) | | 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
| 🔗 应用 | 创建妙搭Spark/Miaoda应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 | | 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
## 安装与快速开始 ## 安装与快速开始
@@ -280,8 +280,6 @@ lark-cli schema im.messages.delete
对于较大的改动,建议先通过 Issue 与我们讨论。 对于较大的改动,建议先通过 Issue 与我们讨论。
提交 PR 前,请先阅读 [AGENTS.md](./AGENTS.md),其中列出了贡献者和 AI Agent 使用的本地构建、测试和 PR 检查清单。
## 许可证 ## 许可证
本项目基于 **MIT 许可证** 开源。 本项目基于 **MIT 许可证** 开源。

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"
@@ -91,7 +90,6 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages") cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output") cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)") cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
@@ -124,13 +122,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 +152,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 +195,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,17 +232,17 @@ 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})
} }
resp, err := ac.DoAPI(opts.Ctx, request) resp, err := ac.DoAPI(opts.Ctx, request)
if err != nil { if err != nil {
// MarkRaw tells the dispatcher to skip the legacy enrichPermissionError // MarkRaw tells the dispatcher to skip enrichPermissionError so the
// pass on *output.ExitError values. Typed *errs.* errors that flow // raw API error detail (log_id, troubleshooter, permission_violations)
// through here keep their canonical message / hint from BuildAPIError; // stays on the wire — `lark-cli api` callers explicitly want the raw
// MarkRaw is a no-op on those (it only flips a flag on *ExitError). // envelope.
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,
@@ -270,16 +253,16 @@ func apiRun(opts *APIOptions) error {
FileIO: f.ResolveFileIO(opts.Ctx), FileIO: f.ResolveFileIO(opts.Ctx),
CommandPath: opts.Cmd.CommandPath(), CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As, Identity: opts.As,
// CheckResponse routes through errclass.BuildAPIError for known Lark // Stage 1: CheckResponse emits the legacy *output.ExitError envelope.
// codes (typed PermissionError / AuthenticationError / ...). For // Per-domain migration in stage 2+ will route through
// unknown codes it falls back to *errs.APIError. The Brand+AppID on // errclass.BuildAPIError to populate identity-aware fields
// the client populate identity-aware fields (ConsoleURL etc.). // (PermissionError.ConsoleURL needs Brand+AppID from the client).
CheckError: ac.CheckResponse, CheckError: ac.CheckResponse,
}) })
// MarkRaw: see comment above on the DoAPI path. Skips legacy // MarkRaw: see comment above on the DoAPI path. Applies equally to
// *ExitError enrichment; typed errors flow through unchanged. // HandleResponse failures so the raw API error survives to the wire.
if err != nil { if err != nil {
return errs.MarkRaw(err) return output.MarkRaw(err)
} }
return nil return nil
} }
@@ -288,76 +271,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,16 +4,11 @@
package api package api
import ( import (
"context"
"encoding/json"
"errors"
"os" "os"
"sort" "sort"
"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"
@@ -69,24 +64,6 @@ func TestApiCmd_DryRun(t *testing.T) {
} }
} }
// Regression: --params null parses to a nil map; writing page_size onto it must
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
// write into the map ParseJSONMap returns.
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err)
}
if out := stdout.String(); !strings.Contains(out, "page_size") {
t.Errorf("expected page_size applied over null --params, got:\n%s", out)
}
}
func TestApiCmd_BotMode(t *testing.T) { func TestApiCmd_BotMode(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -104,19 +81,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 +308,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 +322,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 +334,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 +375,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
@@ -988,69 +670,3 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
t.Errorf("expected dry-run header, got: %s", out) t.Errorf("expected dry-run header, got: %s", out)
} }
} }
// TestApiCmd_PermissionError_DerivesFirstClassFields pins that when a Lark
// API returns a missing-scope failure, the typed *errs.PermissionError
// surfaced by `lark-cli api` lifts the diagnostic signals BuildAPIError
// consumed during classification into first-class wire fields
// (MissingScopes, LogID, ConsoleURL). The wire shape is the typed envelope
// — there is no raw-payload passthrough; new Lark diagnostic fields require
// a CLI release.
func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test_perm", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/docx/v1/documents/test",
Body: map[string]interface{}{
"code": 99991679,
"msg": "scope missing",
"log_id": "20260527-test-log",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docx:document"},
},
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "docx:document" {
t.Errorf("MissingScopes = %v, want [docx:document]", pe.MissingScopes)
}
if pe.LogID != "20260527-test-log" {
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
}
}
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if gotOpts.Method != "GET" {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}

View File

@@ -17,7 +17,6 @@ import (
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"
"github.com/larksuite/cli/internal/errclass"
) )
// NewCmdAuth creates the auth command with subcommands. // NewCmdAuth creates the auth command with subcommands.
@@ -71,7 +70,7 @@ func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (ope
var resp userInfoResponse var resp userInfoResponse
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return "", "", fmt.Errorf("failed to parse user info: %w", err) return "", "", fmt.Errorf("failed to parse user info: %v", err)
} }
if resp.Code != 0 { if resp.Code != 0 {
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg) return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
@@ -111,11 +110,6 @@ type appInfoResponse struct {
} `json:"data"` } `json:"data"`
} }
// getAppInfoFn is the package-level seam used by callers (scopes.go) so tests
// can substitute a fake without standing up a full SDK + httpmock pipeline.
// Mirrors the pollDeviceToken pattern in login.go.
var getAppInfoFn = getAppInfo
// getAppInfo queries app info from the Lark API. // getAppInfo queries app info from the Lark API.
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) { func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
ac, err := f.NewAPIClient() ac, err := f.NewAPIClient()
@@ -137,10 +131,10 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
var resp appInfoResponse var resp appInfoResponse
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err) return nil, fmt.Errorf("failed to parse response: %v", err)
} }
if resp.Code != 0 { if resp.Code != 0 {
return nil, classifyAppInfoErr(apiResp.RawBody, resp.Code, resp.Msg, f, appId) return nil, fmt.Errorf("API error [%d]: %s", resp.Code, resp.Msg)
} }
app := resp.Data.App app := resp.Data.App
@@ -159,21 +153,3 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil
} }
// classifyAppInfoErr re-decodes the raw body so BuildAPIError sees the
// upstream `error` block — the typed appInfoResponse shape drops it.
func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory, appId string) error {
var raw map[string]any
_ = json.Unmarshal(rawBody, &raw)
if raw == nil {
raw = map[string]any{}
}
raw["code"] = code
raw["msg"] = msg
cc := errclass.ClassifyContext{Identity: string(core.AsBot)}
if cfg, _ := f.Config(); cfg != nil {
cc.Brand = string(cfg.Brand)
cc.AppID = appId
}
return errclass.BuildAPIError(raw, cc)
}

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"
@@ -91,29 +90,6 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *CheckOptions
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthLogoutCmd_FlagParsing(t *testing.T) { func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -132,27 +108,6 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *LogoutOptions
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthListCmd_FlagParsing(t *testing.T) { func TestAuthListCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -170,27 +125,6 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ListOptions
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_FlagParsing(t *testing.T) { func TestAuthStatusCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -210,29 +144,6 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_VerifyFlag(t *testing.T) { func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -355,32 +266,6 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--format", "pretty", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) { func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
@@ -433,54 +318,6 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
} }
} }
// TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError pins that when
// the Lark API returns a permission code (99991679 with permission_violations),
// getAppInfo classifies it as *errs.PermissionError carrying the server-
// supplied MissingScopes — not a bare error wrapped as InternalError.
func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/application/v6/applications/test-app",
Body: map[string]interface{}{
"code": 99991679,
"msg": "scope missing",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "application:application:self_manage"},
},
},
},
})
err := authScopesRun(&ScopesOptions{
Factory: f,
Ctx: context.Background(),
Format: "json",
})
if err == nil {
t.Fatal("expected error, got nil")
}
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "application:application:self_manage" {
t.Errorf("MissingScopes = %v, want server-supplied [application:application:self_manage]", pe.MissingScopes)
}
var intErr *errs.InternalError
if errors.As(err, &intErr) {
t.Error("Lark business error must not be wrapped as InternalError; permission semantics lost")
}
}
type authScopesTokenResolver struct { type authScopesTokenResolver struct {
requests []credential.TokenSpec requests []credential.TokenSpec
} }
@@ -552,8 +389,15 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
if matched != nil && matched != cmd && !matched.SilenceUsage { if matched != nil && matched != cmd && !matched.SilenceUsage {
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand") t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
} }
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { var exitErr *output.ExitError
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
} }
}) })
} }

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/output" "github.com/larksuite/cli/internal/output"
@@ -19,7 +18,6 @@ import (
type CheckOptions struct { type CheckOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
Scope string Scope string
JSON bool
} }
// NewCmdAuthCheck creates the auth check subcommand. // NewCmdAuthCheck creates the auth check subcommand.
@@ -38,7 +36,6 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
} }
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)") cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.MarkFlagRequired("scope") cmd.MarkFlagRequired("scope")
cmdutil.SetRisk(cmd, "read") cmdutil.SetRisk(cmd, "read")
@@ -50,7 +47,7 @@ func authCheckRun(opts *CheckOptions) error {
required := strings.Fields(opts.Scope) required := strings.Fields(opts.Scope)
if len(required) == 0 { if len(required) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be empty").WithParam("--scope") return output.ErrValidation("--scope cannot be empty")
} }
config, err := f.Config() config, err := f.Config()

View File

@@ -1,164 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"testing"
"time"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/zalando/go-keyring"
)
// `lark-cli auth check` is a predicate command: its README contract is
// `exit 0 = ok, 1 = missing`. The JSON answer goes to stdout; stderr stays
// empty so callers can write `if lark-cli auth check ...; then ... fi`
// without their logs getting polluted by an error envelope on the negative
// branch. These tests pin that contract end-to-end through the dispatcher.
func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
// UserOpenId left empty: triggers the not_logged_in branch.
})
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
}
var bare *output.BareError
if !errors.As(err, &bare) {
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty for predicate negative answer, got:\n%s", stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != false {
t.Errorf("stdout.ok = %v, want false", payload["ok"])
}
if payload["error"] != "not_logged_in" {
t.Errorf("stdout.error = %v, want 'not_logged_in'", payload["error"])
}
}
func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
UserOpenId: "ou_user", UserName: "tester",
})
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit code = %d, want 1", got)
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty, got:\n%s", stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v", err)
}
if payload["ok"] != false {
t.Errorf("stdout.ok = %v, want false", payload["ok"])
}
if payload["error"] != "no_token" {
t.Errorf("stdout.error = %v, want 'no_token'", payload["error"])
}
}
func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
// Predicate command happy path: stored token covers every required
// scope. Exit must be 0 (nil error, not ErrBare), stdout carries the
// `{"ok":true,...}` JSON answer, and stderr stays empty so shell
// callers can rely on `if lark-cli auth check ...; then` without log
// pollution. Pairs with the two exit-1 negatives above so both
// branches of the predicate contract are pinned.
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_user",
UserName: "tester",
}
now := time.Now()
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: cfg.AppID,
UserOpenId: cfg.UserOpenId,
AccessToken: "user-access-token",
RefreshToken: "refresh-token",
ExpiresAt: now.Add(time.Hour).UnixMilli(),
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
GrantedAt: now.Add(-time.Hour).UnixMilli(),
Scope: "im:message docx:document",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
err := authCheckRun(&CheckOptions{Factory: f, Scope: "im:message"})
if err != nil {
t.Fatalf("expected nil error for happy path (exit 0), got %v", err)
}
if got := output.ExitCodeOf(err); got != 0 {
t.Errorf("exit code = %d, want 0", got)
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty for predicate exit-0 answer, got:\n%s", stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
granted, ok := payload["granted"].([]any)
if !ok || len(granted) != 1 || granted[0] != "im:message" {
t.Errorf("stdout.granted = %v, want [im:message]", payload["granted"])
}
if payload["missing"] != nil {
t.Errorf("stdout.missing = %v, want nil/absent on happy path", payload["missing"])
}
if _, has := payload["suggestion"]; has {
t.Errorf("stdout.suggestion must be absent on happy path; got %v", payload["suggestion"])
}
}
func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
// Scope validation is a real input error, not a predicate negative
// answer — it must surface as a typed ValidationError with the normal
// stderr envelope, distinct from the silent ErrBare predicate path.
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
err := authCheckRun(&CheckOptions{Factory: f, Scope: " "})
if err == nil {
t.Fatal("expected validation error for empty --scope")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
}
}

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"
@@ -19,7 +18,6 @@ import (
// ListOptions holds all inputs for auth list. // ListOptions holds all inputs for auth list.
type ListOptions struct { type ListOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
JSON bool
} }
// NewCmdAuthList creates the auth list subcommand. // NewCmdAuthList creates the auth list subcommand.
@@ -36,7 +34,6 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
return authListRun(opts) return authListRun(opts)
}, },
} }
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read") cmdutil.SetRisk(cmd, "read")
return cmd return cmd
@@ -47,20 +44,12 @@ func authListRun(opts *ListOptions) error {
multi, _ := core.LoadMultiAppConfig() multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 { if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_configured",
})
return nil
}
// auth list is a read-only probe; the "configured but no users" // auth list is a read-only probe; the "configured but no users"
// branch below already returns exit 0 with a stderr hint, so we // branch below already returns exit 0 with a stderr hint, so we
// 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 != "" {
@@ -72,14 +61,6 @@ func authListRun(opts *ListOptions) error {
app := multi.CurrentAppConfig(f.Invocation.Profile) app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 { if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.") fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
return nil return nil
} }

View File

@@ -4,7 +4,6 @@
package auth package auth
import ( import (
"encoding/json"
"strings" "strings"
"testing" "testing"
@@ -35,33 +34,6 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
} }
} }
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the // TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
// reason this hint exists workspace-aware in the first place: an AI agent // reason this hint exists workspace-aware in the first place: an AI agent
// in OpenClaw / Hermes that probes auth list before binding gets routed to // in OpenClaw / Hermes that probes auth list before binding gets routed to
@@ -85,48 +57,3 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
t.Errorf("agent hint must not mention config init: %s", out) t.Errorf("agent hint must not mention config init: %s", out)
} }
} }
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "No logged-in users") {
t.Errorf("stderr = %q, want no-users hint", stderr.String())
}
}

View File

@@ -13,12 +13,9 @@ 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"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output" "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"
@@ -56,9 +53,9 @@ run --device-code in a later step after the user confirms authorization. Use 'la
to generate QR codes (supports ASCII and PNG formats).`, to generate QR codes (supports ASCII and PNG formats).`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot { if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
return errs.NewValidationError(errs.SubtypeInvalidArgument, return output.ErrWithHint(output.ExitValidation, "command_denied",
"strict mode is %q, user login is disabled in this profile", mode). fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
WithHint("if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") "if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
} }
opts.Ctx = cmd.Context() opts.Ctx = cmd.Context()
if runF != nil { if runF != nil {
@@ -124,7 +121,7 @@ func authLoginRun(opts *LoginOptions) error {
} }
// Determine UI language from saved config // Determine UI language from saved config
var lang i18n.Lang lang := "zh"
if multi, _ := core.LoadMultiAppConfig(); multi != nil { if multi, _ := core.LoadMultiAppConfig(); multi != nil {
if app := multi.FindApp(config.ProfileName); app != nil { if app := multi.FindApp(config.ProfileName); app != nil {
lang = app.Lang lang = app.Lang
@@ -160,14 +157,14 @@ func authLoginRun(opts *LoginOptions) error {
for _, d := range selectedDomains { for _, d := range selectedDomains {
if !knownDomains[d] { if !knownDomains[d] {
if suggestion := suggestDomain(d, knownDomains); suggestion != "" { if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain") return output.ErrValidation("unknown domain %q, did you mean %q?", d, suggestion)
} }
available := make([]string, 0, len(knownDomains)) available := make([]string, 0, len(knownDomains))
for k := range knownDomains { for k := range knownDomains {
available = append(available, k) available = append(available, k)
} }
sort.Strings(available) sort.Strings(available)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain") return output.ErrValidation("unknown domain %q, available domains: %s", d, strings.Join(available, ", "))
} }
} }
} }
@@ -175,17 +172,17 @@ func authLoginRun(opts *LoginOptions) error {
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0 hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
if len(opts.Exclude) > 0 && !hasAnyOption { if len(opts.Exclude) > 0 && !hasAnyOption {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude") return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified")
} }
if !hasAnyOption { if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal { if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand) result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
if err != nil { if err != nil {
return err return err
} }
if result == nil { if result == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected") return output.ErrValidation("no login options selected")
} }
selectedDomains = result.Domains selectedDomains = result.Domains
scopeLevel = result.ScopeLevel scopeLevel = result.ScopeLevel
@@ -201,7 +198,7 @@ func authLoginRun(opts *LoginOptions) error {
log(msg.HintFooter) log(msg.HintFooter)
log("") log("")
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.") log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope") return output.ErrValidation("please specify the scopes to authorize")
} }
} }
@@ -230,7 +227,7 @@ func authLoginRun(opts *LoginOptions) error {
} }
if len(candidateScopes) == 0 && opts.Scope == "" { if len(candidateScopes) == 0 && opts.Scope == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options") return output.ErrValidation("no matching scopes found, check domain/scope options")
} }
// Merge --scope additively with the resolved domain scopes. // Merge --scope additively with the resolved domain scopes.
@@ -250,13 +247,13 @@ func authLoginRun(opts *LoginOptions) error {
if len(opts.Exclude) > 0 { if len(opts.Exclude) > 0 {
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude) excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
if len(unknown) > 0 { if len(unknown) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, return output.ErrValidation(
"these --exclude scopes are not present in the requested set: %s", "these --exclude scopes are not present in the requested set: %s",
strings.Join(unknown, ", ")).WithParam("--exclude") strings.Join(unknown, ", "))
} }
finalScope = excluded finalScope = excluded
if strings.TrimSpace(finalScope) == "" { if strings.TrimSpace(finalScope) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no scopes left after applying --exclude; nothing to authorize").WithParam("--exclude") return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize")
} }
} }
@@ -267,7 +264,7 @@ func authLoginRun(opts *LoginOptions) error {
} }
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut) authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
if err != nil { if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err) return output.ErrAuth("device authorization failed: %v", err)
} }
// --no-wait: return immediately with device code and URL // --no-wait: return immediately with device code and URL
@@ -279,28 +276,21 @@ func authLoginRun(opts *LoginOptions) error {
"verification_url": authResp.VerificationUriComplete, "verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode, "device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn, "expires_in": authResp.ExpiresIn,
"hint": "**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it." + "hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
"**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it." +
"**Display order:** Output the URL first, then place the QR code image below the URL." +
"**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation." +
"For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" +
"**After the user confirms authorization:** YOU must execute `lark-cli auth login --device-code <device_code>` yourself." +
"**Do NOT cache verification_url or device_code for future use.** Always run `lark-cli auth login --no-wait --json` fresh when authorization is needed.",
} }
encoder := json.NewEncoder(f.IOStreams.Out) encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false) encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil { if err := encoder.Encode(data); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
} }
return nil return nil
} }
// Step 2: Show user code and verification URL. // Step 2: Show user code and verification URL.
// JSON mode embeds AgentTimeoutHint as a structured field so agents that // Both branches surface AgentTimeoutHint, but on different channels:
// capture stdout into a JSON parser see it without stream-mixing surprises. // JSON mode embeds it as a structured field (so an agent that captures
// Text mode prints the hint to stderr only when running under a non-TTY // stdout into a JSON parser sees it without stream-mixing surprises),
// (i.e. piped / agent harness), since humans reading a terminal don't need // text mode prints to stderr (alongside the URL prompt).
// the agent-oriented instructions.
if opts.JSON { if opts.JSON {
data := map[string]interface{}{ data := map[string]interface{}{
"event": "device_authorization", "event": "device_authorization",
@@ -313,14 +303,12 @@ func authLoginRun(opts *LoginOptions) error {
encoder := json.NewEncoder(f.IOStreams.Out) encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false) encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil { if err := encoder.Encode(data); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
} }
} else { } else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL) fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete) fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
if f.IOStreams != nil && !f.IOStreams.IsTerminal { fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
} }
// Step 3: Poll for token // Step 3: Poll for token
@@ -336,25 +324,25 @@ func authLoginRun(opts *LoginOptions) error {
"event": "authorization_failed", "event": "authorization_failed",
"error": result.Message, "error": result.Message,
}); err != nil { }); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
} }
return output.ErrBare(output.ExitAuth) return output.ErrBare(output.ExitAuth)
} }
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message) return output.ErrAuth("authorization failed: %s", result.Message)
} }
if result.Token == nil { if result.Token == nil {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned") return output.ErrAuth("authorization succeeded but no token returned")
} }
// Step 6: Get user info // Step 6: Get user info
log(msg.AuthSuccess) log(msg.AuthSuccess)
sdk, err := f.LarkClient() sdk, err := f.LarkClient()
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err) return output.ErrAuth("failed to get SDK: %v", err)
} }
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken) openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
if err != nil { if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err) return output.ErrAuth("failed to get user info: %v", err)
} }
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope) scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
@@ -372,13 +360,13 @@ func authLoginRun(opts *LoginOptions) error {
GrantedAt: now, GrantedAt: now,
} }
if err := larkauth.SetStoredToken(storedToken); err != nil { if err := larkauth.SetStoredToken(storedToken); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save token: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
} }
// Step 8: Update config — overwrite Users to single user, clean old tokens // Step 8: Update config — overwrite Users to single user, clean old tokens
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil { if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
_ = larkauth.RemoveStoredToken(config.AppID, openId) _ = larkauth.RemoveStoredToken(config.AppID, openId)
return err return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
} }
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil { if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
@@ -407,11 +395,10 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err) fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
} }
} }
// Skip the stderr hint in JSON mode (the --no-wait call that issued // Skip the stderr hint in JSON mode the --no-wait call that issued the
// the device_code already surfaced it as a JSON field), and also skip it // device_code already returned the hint as a JSON field, and writing
// when running on an interactive terminal — the agent-oriented // text to stderr would pollute consumers that combine streams via 2>&1.
// instructions only matter for piped / harness environments. if !opts.JSON {
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint) fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
} }
log(msg.WaitingAuth) log(msg.WaitingAuth)
@@ -422,22 +409,22 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
if shouldRemoveLoginRequestedScope(result) { if shouldRemoveLoginRequestedScope(result) {
cleanupRequestedScope() cleanupRequestedScope()
} }
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message) return output.ErrAuth("authorization failed: %s", result.Message)
} }
defer cleanupRequestedScope() defer cleanupRequestedScope()
if result.Token == nil { if result.Token == nil {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned") return output.ErrAuth("authorization succeeded but no token returned")
} }
// Get user info // Get user info
log(msg.AuthSuccess) log(msg.AuthSuccess)
sdk, err := f.LarkClient() sdk, err := f.LarkClient()
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err) return output.ErrAuth("failed to get SDK: %v", err)
} }
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken) openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
if err != nil { if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err) return output.ErrAuth("failed to get user info: %v", err)
} }
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope) scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
@@ -455,13 +442,13 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
GrantedAt: now, GrantedAt: now,
} }
if err := larkauth.SetStoredToken(storedToken); err != nil { if err := larkauth.SetStoredToken(storedToken); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save token: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
} }
// Update config — overwrite Users to single user, clean old tokens // Update config — overwrite Users to single user, clean old tokens
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil { if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
_ = larkauth.RemoveStoredToken(config.AppID, openId) _ = larkauth.RemoveStoredToken(config.AppID, openId)
return errs.NewInternalError(errs.SubtypeSDKError, "failed to update login profile: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
} }
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil { if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
@@ -476,18 +463,18 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
func syncLoginUserToProfile(profileName, appID, openID, userName string) error { func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
multi, err := core.LoadMultiAppConfig() multi, err := core.LoadMultiAppConfig()
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err) return fmt.Errorf("load config: %w", err)
} }
app := findProfileByName(multi, profileName) app := findProfileByName(multi, profileName)
if app == nil { if app == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName) return fmt.Errorf("profile %q not found in config", profileName)
} }
oldUsers := append([]core.AppUser(nil), app.Users...) oldUsers := append([]core.AppUser(nil), app.Users...)
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}} app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
if err := core.SaveMultiAppConfig(multi); err != nil { if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err) return fmt.Errorf("save config: %w", err)
} }
for _, oldUser := range oldUsers { for _, oldUser := range oldUsers {

View File

@@ -10,7 +10,6 @@ import (
"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/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
@@ -92,11 +91,16 @@ func buildDomainMeta(name, lang string) domainMeta {
Description: desc, Description: desc,
} }
} }
// Fallback: read from the typed service spec (legacy) // Fallback: read from from_meta spec (legacy)
meta := registry.LoadFromMeta(name)
dm := domainMeta{Name: name} dm := domainMeta{Name: name}
if svc, ok := registry.ServiceTyped(name); ok { if meta != nil {
dm.Title = svc.Title if t, ok := meta["title"].(string); ok {
dm.Description = svc.Description dm.Title = t
}
if d, ok := meta["description"].(string); ok {
dm.Description = d
}
} }
return dm return dm
} }
@@ -158,7 +162,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra
} }
if len(selectedDomains) == 0 { if len(selectedDomains) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain") return nil, output.ErrValidation("no domains selected")
} }
// Compute scope summary // Compute scope summary

View File

@@ -3,8 +3,6 @@
package auth package auth
import "github.com/larksuite/cli/internal/i18n"
type loginMsg struct { type loginMsg struct {
// Interactive UI (login_interactive.go) // Interactive UI (login_interactive.go)
SelectDomains string SelectDomains string
@@ -117,8 +115,8 @@ var loginMsgEn = &loginMsg{
} }
// getLoginMsg returns the login message bundle for the given language. // getLoginMsg returns the login message bundle for the given language.
func getLoginMsg(lang i18n.Lang) *loginMsg { func getLoginMsg(lang string) *loginMsg {
if lang.IsEnglish() { if lang == "en" {
return loginMsgEn return loginMsgEn
} }
return loginMsgZh return loginMsgZh
@@ -128,5 +126,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in // (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json. // service_descriptions.json.
func getShortcutOnlyDomainNames() []string { func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps", "note"} return []string{"base", "contact", "docs", "markdown", "apps"}
} }

View File

@@ -8,8 +8,6 @@ import (
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/internal/i18n"
) )
func TestGetLoginMsg_Zh(t *testing.T) { func TestGetLoginMsg_Zh(t *testing.T) {
@@ -33,7 +31,7 @@ func TestGetLoginMsg_En(t *testing.T) {
} }
func TestGetLoginMsg_DefaultsToZh(t *testing.T) { func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []i18n.Lang{"", "fr_fr", "ja_jp", "unknown"} { for _, lang := range []string{"", "fr", "ja", "unknown"} {
msg := getLoginMsg(lang) msg := getLoginMsg(lang)
if msg != loginMsgZh { if msg != loginMsgZh {
t.Errorf("getLoginMsg(%q) should default to zh", lang) t.Errorf("getLoginMsg(%q) should default to zh", lang)
@@ -63,7 +61,7 @@ func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string)
} }
func TestLoginMsg_FormatStrings(t *testing.T) { func TestLoginMsg_FormatStrings(t *testing.T) {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} { for _, lang := range []string{"zh", "en"} {
msg := getLoginMsg(lang) msg := getLoginMsg(lang)
// LoginSuccess should contain two %s placeholders (userName, openId) // LoginSuccess should contain two %s placeholders (userName, openId)
@@ -104,10 +102,10 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
// --device-code split-flow, and (c) non-streaming harnesses must end the turn // --device-code split-flow, and (c) non-streaming harnesses must end the turn
// after presenting the URL instead of blocking in the same turn. // after presenting the URL instead of blocking in the same turn.
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) { func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} { for _, lang := range []string{"zh", "en"} {
hint := getLoginMsg(lang).AgentTimeoutHint hint := getLoginMsg(lang).AgentTimeoutHint
for _, want := range []string{"--no-wait", "--device-code", "turn"} { for _, want := range []string{"--no-wait", "--device-code", "turn"} {
if lang == i18n.LangZhCN && want == "turn" { if lang == "zh" && want == "turn" {
want = "本轮" want = "本轮"
} }
if !strings.Contains(hint, want) { if !strings.Contains(hint, want) {

View File

@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"strings" "strings"
"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/output" "github.com/larksuite/cli/internal/output"
@@ -172,12 +171,25 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
fmt.Fprintln(f.IOStreams.Out, string(b)) fmt.Fprintln(f.IOStreams.Out, string(b))
return output.ErrBare(output.ExitAuth) return output.ErrBare(output.ExitAuth)
} }
return errs.NewPermissionError(errs.SubtypeMissingScope, "%s", issue.Message). detail := map[string]interface{}{
WithHint("%s", issue.Hint). "requested": issue.Summary.Requested,
WithIdentity("user"). "granted": issue.Summary.Granted,
WithRequestedScopes(issue.Summary.Requested...). "missing": issue.Summary.Missing,
WithGrantedScopes(issue.Summary.Granted...). }
WithMissingScopes(issue.Summary.Missing...) // Legacy *output.ExitError producer: this literal predates the typed
// error contract introduced by errs/. New code MUST NOT construct
// *output.ExitError directly — missing-scope signals should move to
// *errs.PermissionError (with MissingScopes/ConsoleURL as typed
// extension fields) when the login flow migrates to typed errors.
return &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{
Type: "missing_scope",
Message: issue.Message,
Hint: issue.Hint,
Detail: detail,
},
}
} }
fmt.Fprintln(f.IOStreams.ErrOut) fmt.Fprintln(f.IOStreams.ErrOut)

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
)
// TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple asserts that the
// failed-login JSON branch (loginSucceeded == false, opts.JSON == true) wires
// requested + granted + missing scopes into the typed *PermissionError
// envelope. Consumers need the full triple to render actionable diagnostics,
// not just the missing set.
func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
requested := []string{"docx:document", "im:message:send"}
granted := []string{"docx:document"}
missing := []string{"im:message:send"}
err := handleLoginScopeIssue(
&LoginOptions{JSON: true},
getLoginMsg("en"),
f,
&loginScopeIssue{
Message: "scope insufficient",
Hint: "re-login with --scope im:message:send",
Summary: &loginScopeSummary{
Requested: requested,
Granted: granted,
Missing: missing,
},
},
"", // openId empty -> loginSucceeded = false
"tester",
)
if err == nil {
t.Fatal("expected error, got nil")
}
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if !reflect.DeepEqual(permErr.RequestedScopes, requested) {
t.Errorf("RequestedScopes = %v, want %v", permErr.RequestedScopes, requested)
}
if !reflect.DeepEqual(permErr.GrantedScopes, granted) {
t.Errorf("GrantedScopes = %v, want %v", permErr.GrantedScopes, granted)
}
if !reflect.DeepEqual(permErr.MissingScopes, missing) {
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, missing)
}
}

View File

@@ -9,7 +9,6 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"slices"
"sort" "sort"
"strings" "strings"
"testing" "testing"
@@ -215,12 +214,6 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
} }
} }
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
}
}
func TestCollectScopesForDomains(t *testing.T) { func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects() projects := registry.ListFromMetaProjects()
if len(projects) == 0 { if len(projects) == 0 {
@@ -260,15 +253,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)
@@ -416,11 +400,12 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
Granted: []string{"base:app:copy"}, Granted: []string{"base:app:copy"},
}, },
}, "ou_user", "tester") }, "ou_user", "tester")
if err == nil { var exitErr *output.ExitError
t.Fatal("expected error, got nil") if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %v", err)
} }
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { if exitErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth) t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
} }
got := stderr.String() got := stderr.String()
for _, want := range []string{ for _, want := range []string{
@@ -458,11 +443,12 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
Granted: []string{"base:app:copy"}, Granted: []string{"base:app:copy"},
}, },
}, "ou_user", "tester") }, "ou_user", "tester")
if err == nil { var exitErr *output.ExitError
t.Fatal("expected error, got nil") if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %v", err)
} }
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { if exitErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth) t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
} }
var data map[string]interface{} var data map[string]interface{}
@@ -667,11 +653,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
Ctx: context.Background(), Ctx: context.Background(),
Scope: "im:message:send", Scope: "im:message:send",
}) })
if err == nil { var exitErr *output.ExitError
t.Fatal("expected error, got nil") if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %v", err)
} }
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { if exitErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth) t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
} }
got := stderr.String() got := stderr.String()
for _, want := range []string{ for _, want := range []string{
@@ -883,87 +870,6 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
} }
} }
// TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty pins the
// contract that when --json is set and pollDeviceToken returns OK=false,
// stdout carries the structured authorization_failed event and stderr is
// NOT polluted with a typed envelope. The returned error is a bare
// BareError with ExitAuth so the dispatcher only propagates the exit code
// without emitting a second envelope on top of the JSON event.
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
}
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 0,
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
JSON: true,
})
if err == nil {
t.Fatal("expected error for aborted authorization")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
}
// stdout: device_authorization event + authorization_failed event,
// the latter carrying the abort message as a structured field.
stdoutStr := stdout.String()
if !strings.Contains(stdoutStr, `"event":"authorization_failed"`) {
t.Errorf("stdout missing authorization_failed event, got: %s", stdoutStr)
}
if !strings.Contains(stdoutStr, "user denied") {
t.Errorf("stdout missing abort message, got: %s", stdoutStr)
}
// stderr must NOT carry a typed envelope: ErrBare propagates the exit
// code only, so the dispatcher emits nothing on stderr. The waiting-auth
// log line goes through the JSON-mode no-op `log` helper so it is also
// suppressed in JSON mode.
stderrStr := stderr.String()
if strings.Contains(stderrStr, `"type":"authentication"`) {
t.Errorf("stderr should not contain typed envelope, got: %s", stderrStr)
}
if strings.Contains(stderrStr, `"error"`) {
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
}
// Returned error must be the bare *output.BareError signal (no envelope).
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
}
if bareErr.Code != output.ExitAuth {
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
}
}
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) { func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default", ProfileName: "default",
@@ -1055,11 +961,8 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
"final message of the turn", "final message of the turn",
"return control to the user", "return control to the user",
"do not block on --device-code in the same turn", "do not block on --device-code in the same turn",
"come back and notify", "After the user confirms authorization in a later step",
"YOU must execute", "lark-cli auth login --device-code device-code",
"lark-cli auth login --device-code <device_code>",
"Do NOT cache",
"lark-cli auth login --no-wait --json",
} { } {
if !strings.Contains(hint, want) { if !strings.Contains(hint, want) {
t.Fatalf("hint missing %q, got:\n%s", want, hint) t.Fatalf("hint missing %q, got:\n%s", want, hint)

View File

@@ -8,7 +8,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"
@@ -18,7 +17,6 @@ import (
// LogoutOptions holds all inputs for auth logout. // LogoutOptions holds all inputs for auth logout.
type LogoutOptions struct { type LogoutOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
JSON bool
} }
// NewCmdAuthLogout creates the auth logout subcommand. // NewCmdAuthLogout creates the auth logout subcommand.
@@ -35,7 +33,6 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
return authLogoutRun(opts) return authLogoutRun(opts)
}, },
} }
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "write") cmdutil.SetRisk(cmd, "write")
return cmd return cmd
@@ -46,64 +43,24 @@ func authLogoutRun(opts *LogoutOptions) error {
multi, _ := core.LoadMultiAppConfig() multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 { if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_configured",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.") fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
return nil return nil
} }
app := multi.CurrentAppConfig(f.Invocation.Profile) app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 { if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.") fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
return nil return nil
} }
httpClient, httpErr := f.HttpClient()
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
for _, user := range app.Users { for _, user := range app.Users {
if httpErr == nil && secretErr == nil {
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
revokeToken := token.RefreshToken
tokenTypeHint := "refresh_token"
if revokeToken == "" {
revokeToken = token.AccessToken
tokenTypeHint = "access_token"
}
if revokeToken != "" {
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
}
}
}
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil { if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err) fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
} }
} }
app.Users = []core.AppUser{} app.Users = []core.AppUser{}
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)
}
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": true,
})
return nil
} }
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out") output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
return nil return nil

View File

@@ -1,356 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"net/url"
"strings"
"testing"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
t.Helper()
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "test-app",
Apps: []core.AppConfig{
{
AppId: "test-app",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
Users: users,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != true {
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
}
if _, hasReason := payload["reason"]; hasReason {
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "Logged out") {
t.Errorf("stderr = %q, want success text", stderr.String())
}
}
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-refresh-token" &&
values.Get("token_type_hint") == "refresh_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Status: 500,
Body: map[string]interface{}{"error": "server_error"},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
gotErr := stderr.String()
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
}
if !strings.Contains(gotErr, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", gotErr)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}

View File

@@ -13,8 +13,8 @@ import (
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
"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/validate" "github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/internal/vfs"
) )
@@ -63,7 +63,7 @@ For ASCII output, the result is printed to stdout with fixed size.`,
// runQRCode executes the auth qrcode command. // runQRCode executes the auth qrcode command.
func runQRCode(opts *QRCodeOptions) error { func runQRCode(opts *QRCodeOptions) error {
if opts.URL == "" { if opts.URL == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "url is required").WithParam("--url") return output.Errorf(output.ExitValidation, "missing_url", "url is required")
} }
if opts.ASCII { if opts.ASCII {
@@ -75,20 +75,20 @@ func runQRCode(opts *QRCodeOptions) error {
} }
if opts.Output == "" { if opts.Output == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.").WithParam("--output") return output.Errorf(output.ExitValidation, "missing_output", "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.")
} }
if opts.Size < 32 { if opts.Size < 32 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at least 32, got %d", opts.Size).WithParam("--size") return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size))
} }
if opts.Size > 1024 { if opts.Size > 1024 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at most 1024, got %d", opts.Size).WithParam("--size") return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at most 1024, got %d", opts.Size))
} }
safePath, err := validate.SafeOutputPath(opts.Output) safePath, err := validate.SafeOutputPath(opts.Output)
if err != nil { if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) return output.ErrValidation("unsafe output path: %s", err)
} }
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil { if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
@@ -108,7 +108,7 @@ func runQRCode(opts *QRCodeOptions) error {
encoder := json.NewEncoder(out) encoder := json.NewEncoder(out)
encoder.SetEscapeHTML(false) encoder.SetEscapeHTML(false)
if err := encoder.Encode(result); err != nil { if err := encoder.Encode(result); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write output: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", err)
} }
return nil return nil
@@ -118,12 +118,12 @@ func runQRCode(opts *QRCodeOptions) error {
func generateImageQRCode(url string, size int, outputPath string) error { func generateImageQRCode(url string, size int, outputPath string) error {
png, err := qrcode.Encode(url, qrcode.Medium, size) png, err := qrcode.Encode(url, qrcode.Medium, size)
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to encode QR code: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err))
} }
err = vfs.WriteFile(outputPath, png, 0644) err = vfs.WriteFile(outputPath, png, 0644)
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write QR code to %s: %v", outputPath, err).WithCause(err) return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, err))
} }
return nil return nil
@@ -133,7 +133,7 @@ func generateImageQRCode(url string, size int, outputPath string) error {
func generateASCIIQRCode(url string, w io.Writer) error { func generateASCIIQRCode(url string, w io.Writer) error {
q, err := qrcode.New(url, qrcode.Medium) q, err := qrcode.New(url, qrcode.Medium)
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to create QR code: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err))
} }
fmt.Fprint(w, q.ToSmallString(false)) fmt.Fprint(w, q.ToSmallString(false))

View File

@@ -5,6 +5,7 @@ package auth
import ( import (
"encoding/json" "encoding/json"
"errors"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
@@ -170,15 +171,29 @@ func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
func TestRunQRCode_MissingURL(t *testing.T) { func TestRunQRCode_MissingURL(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: ""}) err := runQRCode(&QRCodeOptions{URL: ""})
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { var exitErr *output.ExitError
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "missing_url" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_url")
} }
} }
func TestRunQRCode_MissingOutput(t *testing.T) { func TestRunQRCode_MissingOutput(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256}) err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { var exitErr *output.ExitError
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "missing_output" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_output")
} }
} }
@@ -188,8 +203,15 @@ func TestRunQRCode_InvalidSize(t *testing.T) {
Size: 16, Size: 16,
Output: "qr.png", Output: "qr.png",
}) })
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { var exitErr *output.ExitError
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "invalid_size" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
} }
} }
@@ -199,8 +221,15 @@ func TestRunQRCode_SizeTooLarge(t *testing.T) {
Size: 2048, Size: 2048,
Output: "qr.png", Output: "qr.png",
}) })
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { var exitErr *output.ExitError
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "invalid_size" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
} }
} }
@@ -210,8 +239,12 @@ func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
Size: 256, Size: 256,
Output: "/etc/passwd", Output: "/etc/passwd",
}) })
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { var exitErr *output.ExitError
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
} }
} }
@@ -296,8 +329,15 @@ func TestGenerateImageQRCode_WriteError(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error writing to nonexistent directory") t.Fatal("expected error writing to nonexistent directory")
} }
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitInternal { var exitErr *output.ExitError
t.Errorf("exit code = %d, want %d", gotCode, output.ExitInternal) if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitInternal {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
}
if exitErr.Detail.Type != "write_error" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "write_error")
} }
} }
@@ -318,7 +358,11 @@ func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error for empty string") t.Fatal("expected error for empty string")
} }
if err == nil { var exitErr *output.ExitError
t.Fatal("expected error, got nil") if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail.Type != "encode_error" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "encode_error")
} }
} }

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/output" "github.com/larksuite/cli/internal/output"
) )
@@ -19,7 +18,6 @@ type ScopesOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
Ctx context.Context Ctx context.Context
Format string Format string
JSON bool
} }
// NewCmdAuthScopes creates the auth scopes subcommand. // NewCmdAuthScopes creates the auth scopes subcommand.
@@ -31,9 +29,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
Short: "Query scopes enabled for the app", Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context() opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
}
if runF != nil { if runF != nil {
return runF(opts) return runF(opts)
} }
@@ -42,7 +37,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
} }
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read") cmdutil.SetRisk(cmd, "read")
return cmd return cmd
@@ -56,23 +50,11 @@ func authScopesRun(opts *ScopesOptions) error {
return err return err
} }
fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n") fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n")
appInfo, err := getAppInfoFn(opts.Ctx, f, config.AppID) appInfo, err := getAppInfo(opts.Ctx, f, config.AppID)
if err != nil { if err != nil {
// Discriminate by error type so transport / parse failures are not return output.ErrWithHint(output.ExitAPI, "permission",
// reclassified as PermissionError(MissingScope) — re-auth does not fmt.Sprintf("failed to get app scope info: %v", err),
// fix network / 5xx / JSON parse errors and misclassifying them "ensure the app has enabled the application:application:self_manage scope.")
// here would mislead agents into re-auth loops.
// - typed errors pass through unchanged
// - bare errors become InternalError(SubtypeSDKError) with Cause
// preserved so callers (errors.Is) can still see the underlying
// transport/parse failure.
// Genuine permission failures are surfaced from appInfo *content*,
// not from this transport-level error path.
if errs.IsTyped(err) {
return err
}
return errs.NewInternalError(errs.SubtypeSDKError,
"failed to get app scope info: %v", err).WithCause(err)
} }
if opts.Format == "pretty" { if opts.Format == "pretty" {
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID) fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)

View File

@@ -1,121 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"errors"
"fmt"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// stubGetAppInfoErr swaps getAppInfoFn for the duration of t so authScopesRun
// observes a fixed error from the dependency. t.Cleanup restores the prior
// value so tests cannot leak through the package-level seam.
func stubGetAppInfoErr(t *testing.T, errToReturn error) {
t.Helper()
prev := getAppInfoFn
getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
return nil, errToReturn
}
t.Cleanup(func() { getAppInfoFn = prev })
}
// scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive
// authScopesRun. Config has a non-empty AppID so we get past the config gate
// and reach the getAppInfoFn call.
func scopesTestFactory(t *testing.T) *ScopesOptions {
t.Helper()
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
})
return &ScopesOptions{
Factory: f,
Ctx: context.Background(),
Format: "json",
}
}
// TestAuthScopesRun_NetworkErrorPassedThrough pins that a typed NetworkError
// surfaced by the dependency is not re-classified as PermissionError —
// re-auth does not fix DNS / transport failures and blanket-wrapping them
// would mislead agents into infinite re-auth loops.
func TestAuthScopesRun_NetworkErrorPassedThrough(t *testing.T) {
netErr := errs.NewNetworkError(errs.SubtypeNetworkDNS, "DNS lookup failed")
stubGetAppInfoErr(t, netErr)
err := authScopesRun(scopesTestFactory(t))
if err == nil {
t.Fatal("expected error, got nil")
}
var permErr *errs.PermissionError
if errors.As(err, &permErr) {
t.Errorf("network failure must not be classified as PermissionError; got %v", permErr)
}
var gotNet *errs.NetworkError
if !errors.As(err, &gotNet) {
t.Fatalf("network failure not preserved through authScopesRun; got %T: %v", err, err)
}
if gotNet != netErr {
t.Errorf("typed network error should pass through identity-stable; got %p, want %p", gotNet, netErr)
}
}
// TestAuthScopesRun_PermissionErrorPassedThrough pins that typed permission
// failures from the dependency also pass through — IsTyped() must not single
// out one category.
func TestAuthScopesRun_PermissionErrorPassedThrough(t *testing.T) {
permErr := errs.NewPermissionError(errs.SubtypeMissingScope, "scope X missing").
WithMissingScopes("im:message")
stubGetAppInfoErr(t, permErr)
err := authScopesRun(scopesTestFactory(t))
if err == nil {
t.Fatal("expected error, got nil")
}
var got *errs.PermissionError
if !errors.As(err, &got) {
t.Fatalf("expected *PermissionError pass-through, got %T: %v", err, err)
}
if got != permErr {
t.Errorf("typed permission error should pass through identity-stable; got %p, want %p", got, permErr)
}
}
// TestAuthScopesRun_BareErrorWrappedAsInternal pins the unclassified branch:
// a bare error (e.g. json.Unmarshal failure inside getAppInfo) surfaces as
// *InternalError{SubtypeSDKError} with the original error preserved on
// Cause so errors.Is still walks to it.
func TestAuthScopesRun_BareErrorWrappedAsInternal(t *testing.T) {
bareErr := fmt.Errorf("failed to parse response: unexpected EOF")
stubGetAppInfoErr(t, bareErr)
err := authScopesRun(scopesTestFactory(t))
if err == nil {
t.Fatal("expected error, got nil")
}
var permErr *errs.PermissionError
if errors.As(err, &permErr) {
t.Errorf("bare getAppInfo error must not be classified as PermissionError; got %v", permErr)
}
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *InternalError, got %T: %v", err, err)
}
if intErr.Subtype != errs.SubtypeSDKError {
t.Errorf("InternalError.Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError)
}
if !errors.Is(err, bareErr) {
t.Error("InternalError must carry bareErr via WithCause so errors.Is walks to it")
}
}

View File

@@ -17,7 +17,6 @@ import (
type StatusOptions struct { type StatusOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
Verify bool Verify bool
JSON bool
} }
// NewCmdAuthStatus creates the auth status subcommand. // NewCmdAuthStatus creates the auth status subcommand.
@@ -36,7 +35,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
} }
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)") cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read") cmdutil.SetRisk(cmd, "read")
return cmd return cmd
@@ -63,6 +61,7 @@ func authStatusRun(opts *StatusOptions) error {
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify) diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
result["identities"] = diagnostics result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics) result["identity"] = effectiveIdentity(diagnostics)
addLegacyUserFields(result, diagnostics.User)
addEffectiveVerification(result, diagnostics) addEffectiveVerification(result, diagnostics)
addStatusNote(result, diagnostics) addStatusNote(result, diagnostics)
@@ -87,6 +86,29 @@ func effectiveIdentity(d identitydiag.Result) string {
} }
} }
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
if user.OpenID == "" {
return
}
result["userName"] = user.UserName
result["userOpenId"] = user.OpenID
if user.TokenStatus != "" {
result["tokenStatus"] = user.TokenStatus
}
if user.Scope != "" {
result["scope"] = user.Scope
}
if user.ExpiresAt != "" {
result["expiresAt"] = user.ExpiresAt
}
if user.RefreshExpiresAt != "" {
result["refreshExpiresAt"] = user.RefreshExpiresAt
}
if user.GrantedAt != "" {
result["grantedAt"] = user.GrantedAt
}
}
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) { func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
switch result["identity"] { switch result["identity"] {
case identityUser: case identityUser:

View File

@@ -6,7 +6,6 @@ package cmd
import ( import (
"context" "context"
"io" "io"
"io/fs"
"github.com/larksuite/cli/cmd/api" "github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth" "github.com/larksuite/cli/cmd/auth"
@@ -17,10 +16,8 @@ import (
"github.com/larksuite/cli/cmd/profile" "github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema" "github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service" "github.com/larksuite/cli/cmd/service"
"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 +31,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.
@@ -58,18 +51,6 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
} }
} }
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
// at build time. It is registered by the repo-root package main's init via
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
// breaking the single-file preview build (see skills_embed.go). nil in builds
// that embed no skills; the `skills` commands then return a typed internal error.
var embeddedSkillContent fs.FS
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
// repo-root package main's init; a wrapper main can call it before Execute to
// supply its own skill content.
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
// HideProfile sets the visibility policy for the root-level --profile flag. // HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still // When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as // parse) but is omitted from help and shell completion. Typically called as
@@ -80,41 +61,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
@@ -157,7 +103,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
if cfg.keychain != nil { if cfg.keychain != nil {
f.Keychain = cfg.keychain f.Keychain = cfg.keychain
} }
f.SkillContent = embeddedSkillContent
rootCmd := &cobra.Command{ rootCmd := &cobra.Command{
Use: "lark-cli", Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls", Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
@@ -172,13 +117,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
installTipsHelpFunc(rootCmd) installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
// inherited by every subcommand, turning unknown-flag errors into a
// structured "did you mean" envelope.
rootCmd.SilenceUsage = true
rootCmd.SetFlagErrorFunc(flagDidYouMean)
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals) RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
@@ -195,27 +133,15 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(completion.NewCmdCompletion(f)) rootCmd.AddCommand(completion.NewCmdCompletion(f))
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)) service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
if !cfg.skipService {
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

@@ -1,160 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"sort"
"strings"
)
// universalFlags are accepted by every command (cobra auto-injects help; the
// root injects version). They are never reported as unknown.
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
// catalog is the source-of-truth command catalog: command path -> accepted flag
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
// "contact +search-user". The root command is the empty path "".
type catalog struct {
flagsByPath map[string]map[string]bool
group map[string]bool // paths that are parent groups (have subcommands)
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
}
func newCatalog() *catalog {
return &catalog{
flagsByPath: map[string]map[string]bool{},
group: map[string]bool{},
}
}
// setGroup records whether path is a parent group (has subcommands). Leftover
// words after a group node are unknown subcommands; after a leaf they are
// positionals (e.g. "api GET /path").
func (c *catalog) setGroup(path string, isGroup bool) {
if isGroup {
c.group[path] = true
}
}
func (c *catalog) isGroup(path string) bool { return c.group[path] }
// addCommand registers a command path and the flags it accepts. Repeated calls
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
func (c *catalog) addCommand(path string, flags []string) {
set := c.flagsByPath[path]
if set == nil {
set = map[string]bool{}
c.flagsByPath[path] = set
}
for _, f := range flags {
set[f] = true
}
c.sorted = nil // invalidate cached suggestion list
}
func (c *catalog) hasCommand(path string) bool {
_, ok := c.flagsByPath[path]
return ok
}
// hasFlag reports whether flag is accepted by command path (universal flags
// always pass).
func (c *catalog) hasFlag(path, flag string) bool {
if universalFlags[flag] {
return true
}
set := c.flagsByPath[path]
return set[flag]
}
// longestPrefix returns the longest known command path that is a prefix of
// words, plus how many words it consumed. This separates real subcommands from
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
// empty it falls back to the root command. ok=false means not even the first
// word names a command.
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
if len(words) == 0 {
if c.hasCommand("") {
return "", 0, true
}
return "", 0, false
}
for i := len(words); i >= 1; i-- {
cand := strings.Join(words[:i], " ")
if c.hasCommand(cand) {
return cand, i, true
}
}
return "", 0, false
}
// paths returns all known command paths, sorted.
func (c *catalog) paths() []string {
out := make([]string, 0, len(c.flagsByPath))
for p := range c.flagsByPath {
out = append(out, p)
}
sort.Strings(out)
return out
}
// suggestCommand returns the known command path closest to want (small edit
// distance), for error hints. Returns "" when nothing is reasonably close.
func (c *catalog) suggestCommand(want string) string {
if c.sorted == nil {
c.sorted = c.paths() // built once after the catalog is fully populated
}
return closest(want, c.sorted)
}
// suggestFlag returns the flag of path closest to flag, for error hints.
func (c *catalog) suggestFlag(path, flag string) string {
set := c.flagsByPath[path]
cands := make([]string, 0, len(set))
for f := range set {
cands = append(cands, f)
}
sort.Strings(cands)
return closest(flag, cands)
}
// closest returns the candidate with the smallest Levenshtein distance to want,
// but only if that distance is within a tolerance scaled to want's length
// (avoids absurd suggestions).
func closest(want string, cands []string) string {
best := ""
bestD := 1 << 30
for _, cand := range cands {
d := levenshtein(want, cand)
if d < bestD {
bestD, best = d, cand
}
}
tol := len(want)/2 + 1
if bestD > tol {
return ""
}
return best
}
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
prev := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur := make([]int, len(rb)+1)
cur[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
}
prev = cur
}
return prev[len(rb)]
}

View File

@@ -1,60 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import "strings"
// Finding kinds.
const (
unknownCommand = "unknown_command"
unknownFlag = "unknown_flag"
)
// finding is a single mismatch between an example command reference and the
// catalog.
type finding struct {
line int
raw string
kind string // unknownCommand | unknownFlag
path string // resolved command path (unknownFlag) or attempted path (unknownCommand)
flag string // offending flag (unknownFlag only)
suggest string // nearest known command/flag, "" if none close
}
// checkRefs validates refs against cat and returns all mismatches in order.
func checkRefs(cat *catalog, refs []ref) []finding {
var out []finding
for _, r := range refs {
path, n, ok := cat.longestPrefix(r.words)
if !ok {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
// Leftover words after a group node are an unknown subcommand (e.g. a
// mistyped method like "batch_modify_message"). After a leaf they are
// positionals (e.g. "api GET /path"), so only groups trigger this.
if n < len(r.words) && cat.isGroup(path) {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
for _, f := range r.flags {
if cat.hasFlag(path, f) {
continue
}
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownFlag,
path: path, flag: f, suggest: cat.suggestFlag(path, f),
})
}
}
return out
}

View File

@@ -1,222 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"regexp"
"strings"
)
// ref is one lark-cli command reference extracted from a shortcut example.
type ref struct {
line int // 1-based line number (the line where the command starts)
raw string // reconstructed command text, for error display
words []string // command words before the first flag (subcommand candidates)
flags []string // flag tokens used, e.g. "--query", "-q"
}
const cliToken = "lark-cli"
// subcommandStart guards against false positives from prose: a real command's
// first word is ASCII (a service name or a +shortcut). A token starting with
// CJK / punctuation is treated as narration, not a command.
var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`)
// shellStops are standalone tokens that terminate a command (pipes, redirects,
// separators). Separators glued to a token (`get;`, `foo|`) are handled inline.
var shellStops = map[string]bool{
"|": true, "||": true, "&&": true, "&": true, ";": true,
">": true, ">>": true, "<": true, "2>": true, "2>&1": true,
}
// wordTrailPunct is sentence / CJK punctuation that can cling to a command word
// in prose ("auth login." / "auth login"); stripped so the word still resolves
// instead of being dropped as an unknown command or non-ASCII narration.
const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』`
// parseRefs extracts every lark-cli command reference from text (a shortcut's
// Tips line, which may embed an "Example: lark-cli ..." command). It is
// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits
// in a ```bash fence, an inline `code` span, or bare prose. Backslash
// line-continuations are joined first so a multi-line invocation is parsed as
// one command; inline-code backticks and trailing # comments terminate it.
func parseRefs(content string) []ref {
var refs []ref
lines := strings.Split(content, "\n")
for i := 0; i < len(lines); i++ {
lineNo := i + 1
logical := lines[i]
// Shell line continuation: a trailing backslash joins the next physical
// line. Without this, flags on the continuation lines of a multi-line
// `lark-cli ... \` example are never seen by the checker.
for endsWithBackslash(logical) && i+1 < len(lines) {
logical = strings.TrimRight(logical, " \t")
logical = logical[:len(logical)-1] // drop the trailing backslash
i++
logical += " " + lines[i]
}
refs = append(refs, parseLine(logical, lineNo)...)
}
return refs
}
func endsWithBackslash(s string) bool {
return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`)
}
func parseLine(line string, lineNo int) []ref {
var refs []ref
rest := line
for {
idx := strings.Index(rest, cliToken)
if idx < 0 {
break
}
after := rest[idx+len(cliToken):]
beforeOK := idx == 0 || isBoundary(rest[idx-1])
afterOK := after == "" || isBoundary(after[0])
if beforeOK && afterOK {
if words, flags, raw, ok := parseCmd(after); ok {
refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags})
}
}
rest = after
}
return refs
}
// parseCmd tokenizes the text following "lark-cli" into leading command words
// (the subcommand path, up to the first flag) and flag tokens. It stops at a
// shell separator (standalone or glued), an inline-code backtick, a comment, or
// a placeholder/prose word. ok=false filters out non-commands.
func parseCmd(after string) (words, flags []string, raw string, ok bool) {
// An inline code span ends at the next backtick; a command never spans one.
if i := strings.IndexByte(after, '`'); i >= 0 {
after = after[:i]
}
// Drop $(...) command substitutions so flags belonging to the inner command
// (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags.
after = stripCmdSubst(after)
var kept []string
inFlags := false
for _, orig := range strings.Fields(after) {
tok := orig
if shellStops[tok] || strings.HasPrefix(tok, "#") {
break
}
// A shell separator glued to a token ends the command mid-token
// ("get;", "foo|next"): keep the part before it, handle it, then stop.
stop := false
if i := strings.IndexAny(tok, ";|"); i >= 0 {
tok, stop = tok[:i], true
}
switch {
case tok == "" || tok == "-":
// empty (after a glued separator) or a bare stdin marker — skip
case strings.HasPrefix(tok, "-"):
if f := normalizeFlag(tok); f != "" {
inFlags = true
flags = append(flags, f)
kept = append(kept, tok)
}
case inFlags:
// positional / flag value after the first flag — not a command word
kept = append(kept, tok)
default:
// Command-path word. ASCII placeholder markers (<x>, [x], {x|y},
// +<verb>, ...) end the command — checked on the RAW token so the
// trailing-punct stripping below cannot erase a "..." ellipsis
// ("base +..." must stay a placeholder, not become "+").
if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") {
stop = true
break
}
// Strip trailing sentence/CJK punctuation so "login." / "login"
// resolve to "login"; non-ASCII narration ends the command.
w := strings.TrimRight(tok, wordTrailPunct)
if w == "" || hasNonASCII(w) {
stop = true
break
}
words = append(words, w)
kept = append(kept, tok)
}
if stop {
break
}
}
if len(kept) > 0 {
raw = " " + strings.Join(kept, " ")
}
// Keep root-only refs ("lark-cli --help") and refs whose first word looks
// like a subcommand; drop prose ("lark-cli 就能搞定 ...").
if len(words) == 0 {
return words, flags, raw, len(flags) > 0
}
if !subcommandStart.MatchString(words[0]) {
return nil, nil, "", false
}
return words, flags, raw, true
}
// stripCmdSubst removes $(...) command substitutions (including nested ones)
// from s, leaving the surrounding text intact. Backtick substitutions are
// already handled upstream (a command never spans a backtick).
func stripCmdSubst(s string) string {
var b strings.Builder
depth := 0
for i := 0; i < len(s); i++ {
if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
depth = 1
i++ // skip '('
continue
}
if depth > 0 {
switch s[i] {
case '(':
depth++
case ')':
depth--
}
continue
}
b.WriteByte(s[i])
}
return b.String()
}
// isPlaceholderOrProse reports whether a command word is a doc placeholder
// (<resource>, [flags], {a|b}, +<verb>, ...) or narration (CJK / other
// non-ASCII), rather than a literal command token.
func isPlaceholderOrProse(w string) bool {
if hasNonASCII(w) {
return true
}
return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...")
}
func hasNonASCII(s string) bool {
return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0
}
// flagShape matches the leading flag token, stripping any trailing junk such as
// a "=value" suffix or punctuation that bled in from the surrounding markdown
// ("--help\"", "--help;", "--params={}"). The underscore is allowed because
// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags.
var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`)
// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not
// a real flag (e.g. a shell-string fragment like "-草稿'").
func normalizeFlag(tok string) string {
return flagShape.FindString(tok)
}
func isBoundary(b byte) bool {
switch b {
case ' ', '\t', '`', '(', ')', '\'', '"', '*':
return true
}
return false
}

View File

@@ -1,113 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// This file and its cmdexample_*_test.go siblings implement a test-only check:
// the example commands embedded in shortcut definitions (the "Example: lark-cli
// ..." lines in each shortcut's Tips, shown in --help) must match the real
// command tree. It lives entirely in _test.go files (package cmd_test) so it
// ships in no binary and is not importable by product code; the truth source is
// cmd.Build, the same tree the binary uses, so the check cannot drift.
//
// It runs in the standard unit-test CI job (go test ./cmd/...). A mismatch — an
// example using a renamed command or an unaccepted flag — fails that job.
package cmd_test
import (
"context"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestShortcutExampleCommands checks the example commands embedded in every
// shortcut's Tips against the live command tree. A shortcut that defines no
// example is simply skipped.
//
// Because the examples and the command definitions live in the same Go code,
// this is a self-consistency check: any mismatch (an example using a renamed
// command or a flag the command doesn't accept) is a bug to fix at the source.
// It runs over all shortcuts — no baseline, no diff — since a wrong example is
// always a defect, never acceptable "pre-existing drift".
func TestShortcutExampleCommands(t *testing.T) {
// Reproducibility: use the embedded API metadata (not a developer's stale
// ~/.lark-cli remote cache, which can miss commands) and an empty config
// dir so local strict mode / plugins / policy cannot reshape the tree.
// t.Setenv auto-restores after the test, so other cmd tests are unaffected.
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cat := buildCmdExampleCatalog()
type located struct {
shortcut string
f finding
}
var findings []located
for _, sc := range shortcuts.AllShortcuts() {
var refs []ref
for _, tip := range sc.Tips {
refs = append(refs, parseRefs(tip)...)
}
label := strings.TrimSpace(sc.Service + " " + sc.Command)
for _, f := range checkRefs(cat, refs) {
findings = append(findings, located{shortcut: label, f: f})
}
}
if len(findings) == 0 {
return
}
sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut })
for _, lf := range findings {
hint := ""
if lf.f.suggest != "" {
hint = " (did you mean " + lf.f.suggest + "?)"
}
if lf.f.kind == unknownFlag {
t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s",
lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
} else {
t.Errorf("shortcut %q example uses unknown command %q%s\n %s",
lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
}
}
t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+
"fix the Example in the shortcut definition.", len(findings))
}
// buildCmdExampleCatalog walks the live cobra command tree and records every
// command path (minus the "lark-cli" root prefix) with its accepted flags and
// whether it is a parent group. This is the same Build() the binary uses, so
// the catalog can never drift from the real commands.
func buildCmdExampleCatalog() *catalog {
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
cat := newCatalog()
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
var flags []string
add := func(fl *pflag.Flag) {
flags = append(flags, "--"+fl.Name)
if fl.Shorthand != "" {
flags = append(flags, "-"+fl.Shorthand)
}
}
c.Flags().VisitAll(add)
c.InheritedFlags().VisitAll(add)
c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile)
cat.addCommand(path, flags)
cat.setGroup(path, c.HasSubCommands())
for _, sub := range c.Commands() {
walk(sub)
}
}
walk(root)
return cat
}

View File

@@ -1,233 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"strings"
"testing"
)
func testCatalog() *catalog {
c := newCatalog()
c.addCommand("", []string{"--profile"}) // root
c.setGroup("", true)
c.addCommand("contact", []string{"--profile"})
c.setGroup("contact", true)
c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"})
c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands)
c.addCommand("mail", nil)
c.setGroup("mail", true)
c.addCommand("mail user_mailbox.messages", []string{"--profile"})
c.setGroup("mail user_mailbox.messages", true)
c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"})
return c
}
func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) {
c := testCatalog()
if !c.hasCommand("contact +search-user") {
t.Fatal("expected contact +search-user to exist")
}
if c.hasCommand("contact +nope") {
t.Fatal("did not expect contact +nope")
}
if !c.hasFlag("contact +search-user", "--query") {
t.Fatal("--query should be valid")
}
if c.hasFlag("contact +search-user", "--nope") {
t.Fatal("--nope should be invalid")
}
// universal flags pass on any command
for _, f := range []string{"--help", "-h", "--version"} {
if !c.hasFlag("contact +search-user", f) {
t.Fatalf("universal flag %s should pass", f)
}
}
}
func TestCmdExampleLongestPrefix(t *testing.T) {
c := testCatalog()
tests := []struct {
words []string
want string
wantN int
wantOK bool
}{
{[]string{"contact", "+search-user"}, "contact +search-user", 2, true},
{[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals
{[]string{"nope"}, "", 0, false},
{nil, "", 0, true}, // empty -> root
}
for _, tt := range tests {
got, n, ok := c.longestPrefix(tt.words)
if got != tt.want || n != tt.wantN || ok != tt.wantOK {
t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)",
tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK)
}
}
}
func refWordsOf(refs []ref) [][]string {
var out [][]string
for _, r := range refs {
out = append(out, r.words)
}
return out
}
func TestCmdExampleParseRefsExtractsCommands(t *testing.T) {
content := strings.Join([]string{
"运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code
"```bash",
"lark-cli api GET /open-apis/x --params '{}'", // bash block
"```",
"用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command
"npx foo | lark-cli api GET /y", // after a pipe
}, "\n")
refs := parseRefs(content)
if len(refs) != 4 {
t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs))
}
if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" ||
len(got.flags) != 1 || got.flags[0] != "--query" {
t.Errorf("ref0 = %+v", got)
}
if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" {
t.Errorf("ref1 words = %v", got.words)
}
}
func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) {
// A line whose first word is prose yields no command at all.
if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 {
t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs))
}
// Syntax templates / trailing prose may leave a real leading word ("mail"),
// but no placeholder or CJK token may leak into the command words — that is
// what prevents false positives like an "<resource>" unknown-command report.
for _, line := range []string{
"lark-cli mail <resource> <method> [flags]",
"lark-cli apps +<verb> [flags]",
"lark-cli base +...",
"lark-cli mail 写信场景下的格式说明",
} {
for _, r := range parseRefs(line) {
for _, w := range r.words {
if isPlaceholderOrProse(w) {
t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words)
}
}
}
}
}
func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) {
// frontmatter-style quoted value: the trailing quote must not bleed into the flag
refs := parseRefs(`cliHelp: "lark-cli contact --help"`)
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" {
t.Errorf("expected flag --help, got %v", refs[0].flags)
}
// bare "-" (stdin marker) and "=value" suffix
refs = parseRefs("lark-cli api GET /x --params={} --data -")
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
flags := strings.Join(refs[0].flags, " ")
if flags != "--params --data" {
t.Errorf("expected '--params --data', got %q", flags)
}
}
func TestCmdExampleCheck(t *testing.T) {
c := testCatalog()
tests := []struct {
name string
r ref
wantKind string // "" = no finding
wantPath string
}{
{"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""},
{"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""},
{"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"},
{"group leftover = unknown subcommand",
ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}},
unknownCommand, "mail user_mailbox.messages batch_modify_message"},
{"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"},
{"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := checkRefs(c, []ref{tt.r})
if tt.wantKind == "" {
if len(fs) != 0 {
t.Fatalf("expected no finding, got %+v", fs)
}
return
}
if len(fs) != 1 {
t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs)
}
if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath {
t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath)
}
})
}
}
func TestCmdExampleCheckSuggestsNearest(t *testing.T) {
c := testCatalog()
fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}})
if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" {
t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs)
}
}
// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after
// review: backslash continuation, underscore flags, $(...) substitution, glued
// separators, trailing punctuation, and the "..." placeholder.
func TestCmdExampleParseRefsRobustness(t *testing.T) {
cases := []struct {
name, content, wantWords, wantFlags string
wantRefs int
}{
{"backslash continuation joins flags",
"lark-cli contact +search-user \\\n --query foo \\\n --as user",
"contact +search-user", "--query --as", 1},
{"underscore flag not truncated",
"lark-cli whiteboard +update --input_format mermaid",
"whiteboard +update", "--input_format", 1},
{"command-substitution flags ignored",
`lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`,
"slides x create", "--data --as", 1},
{"glued separator truncates",
"lark-cli auth login; echo done",
"auth login", "", 1},
{"trailing CJK punctuation stripped",
"用 lark-cli auth login。",
"auth login", "", 1},
{"ellipsis placeholder stays placeholder",
"lark-cli base +...",
"base", "", 1},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
refs := parseRefs(tt.content)
if len(refs) != tt.wantRefs {
t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs))
}
if tt.wantRefs == 0 {
return
}
if got := strings.Join(refs[0].words, " "); got != tt.wantWords {
t.Errorf("words=%q want %q", got, tt.wantWords)
}
if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags {
t.Errorf("flags=%q want %q", got, tt.wantFlags)
}
})
}
}

View File

@@ -1,52 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"reflect"
"testing"
"github.com/spf13/cobra"
)
// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the
// service command tree for any depth — flat dotted resources AND genuinely
// nested resources — so it round-trips through apicatalog.Resolve instead of
// assuming a fixed root->service->resource->method shape.
func TestCommandCatalogPath(t *testing.T) {
chain := func(names ...string) *cobra.Command {
var parent, leaf *cobra.Command
for _, n := range names {
c := &cobra.Command{Use: n}
if parent != nil {
parent.AddCommand(c)
}
parent = c
leaf = c
}
return leaf
}
tests := []struct {
name string
leaf *cobra.Command
want []string
}{
{"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}},
{"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}},
{"service level", chain("lark-cli", "im"), []string{"im"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) {
t.Errorf("commandCatalogPath = %v, want %v", got, tt.want)
}
})
}
// The root command (no parent) has no catalog path.
if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 {
t.Errorf("root path = %v, want empty", got)
}
}

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

@@ -12,10 +12,8 @@ import (
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"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/keychain" "github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
@@ -39,10 +37,8 @@ type BindOptions struct {
// this flag because its own prompts already require human confirmation. // this flag because its own prompts already require human confirmation.
Force bool Force bool
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateBindFlags Lang string
langExplicit bool // true when --lang was explicitly passed langExplicit bool // true when --lang was explicitly passed
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
// Brand holds the resolved Lark product brand ("feishu" | "lark") for // Brand holds the resolved Lark product brand ("feishu" | "lark") for
// the account being bound. Populated after resolveAccount; TUI stages // the account being bound. Populated after resolveAccount; TUI stages
@@ -59,7 +55,7 @@ type BindOptions struct {
// NewCmdConfigBind creates the config bind subcommand. // NewCmdConfigBind creates the config bind subcommand.
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command { func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN} opts := &BindOptions{Factory: f}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "bind", Use: "bind",
@@ -106,7 +102,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)") cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)") cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)") cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)") cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
cmdutil.SetRisk(cmd, "write") cmdutil.SetRisk(cmd, "write")
return cmd return cmd
@@ -151,7 +147,7 @@ func configBindRun(opts *BindOptions) error {
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil { if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
return err return err
} }
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes)) applyPreferences(appConfig, opts)
noticeUserDefaultRisk(opts) noticeUserDefaultRisk(opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath) return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
@@ -182,7 +178,7 @@ type existingBinding struct {
func finalizeSource(opts *BindOptions) (string, error) { func finalizeSource(opts *BindOptions) (string, error) {
explicit := strings.TrimSpace(strings.ToLower(opts.Source)) explicit := strings.TrimSpace(strings.ToLower(opts.Source))
if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" { if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit).WithParam("--source") return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit)
} }
var detected string var detected string
@@ -199,23 +195,23 @@ func finalizeSource(opts *BindOptions) (string, error) {
// before any interactive prompts — running inside Hermes with // before any interactive prompts — running inside Hermes with
// --source openclaw (or vice versa) is almost always a mistake. // --source openclaw (or vice versa) is almost always a mistake.
if explicit != "" && detected != "" && explicit != detected { if explicit != "" && detected != "" && explicit != detected {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, return "", output.ErrWithHint(output.ExitValidation, "bind",
"--source %q does not match detected Agent environment (%s)", explicit, detected). fmt.Sprintf("--source %q does not match detected Agent environment (%s)", explicit, detected),
WithHint("remove --source to auto-detect, or run this command in the correct Agent context"). "remove --source to auto-detect, or run this command in the correct Agent context")
WithParam("--source")
} }
// TUI: prompt for language before any downstream prompts. The source // TUI: prompt for language before any downstream prompts. The source
// selection itself may still be skipped entirely if --source or the // selection itself may still be skipped entirely if --source or the
// env already pinned it. Picker offers 2 options (中文 / English) and // env already pinned it.
// drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering).
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 "", err
} }
opts.Lang = string(lang) opts.Lang = lang
opts.UILang = lang
} }
if explicit != "" { if explicit != "" {
@@ -227,10 +223,9 @@ func finalizeSource(opts *BindOptions) (string, error) {
if opts.IsTUI { if opts.IsTUI {
return tuiSelectSource(opts) return tuiSelectSource(opts)
} }
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, return "", output.ErrWithHint(output.ExitValidation, "bind",
"cannot determine Agent source: no --source flag and no Agent environment detected"). "cannot determine Agent source: no --source flag and no Agent environment detected",
WithHint("pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context"). "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context")
WithParam("--source")
} }
// reconcileExistingBinding reads any existing config at configPath and decides // reconcileExistingBinding reads any existing config at configPath and decides
@@ -250,7 +245,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
return existingBinding{}, err return existingBinding{}, err
} }
if action == "cancel" { if action == "cancel" {
msg := getBindMsg(opts.UILang) msg := getBindMsg(opts.Lang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled) fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
return existingBinding{Cancelled: true}, nil return existingBinding{Cancelled: true}, nil
} }
@@ -334,10 +329,9 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
if !hasStrictBotLock(previousConfigBytes) { if !hasStrictBotLock(previousConfigBytes) {
return nil return nil
} }
msg := getBindMsg(opts.UILang) msg := getBindMsg(opts.Lang)
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, return output.ErrWithHint(output.ExitValidation, "bind",
"config bind --force", "%s", msg.IdentityEscalationMessage). msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
WithHint("%s", msg.IdentityEscalationHint)
} }
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every // noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
@@ -353,23 +347,14 @@ func noticeUserDefaultRisk(opts *BindOptions) {
if opts.IsTUI || opts.Identity != "user-default" { if opts.IsTUI || opts.Identity != "user-default" {
return return
} }
msg := getBindMsg(opts.UILang) msg := getBindMsg(opts.Lang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage) fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
} }
// applyPreferences expands the chosen identity preset into the underlying // applyPreferences expands the chosen identity preset into the underlying
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the // StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
// profile's intent survives later changes to global strict-mode settings. // profile's intent survives later changes to global strict-mode settings.
// preferredLang resolves the language to persist: the requested value when set, func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
// otherwise the prior one — so an unset --lang never clears a stored preference.
func preferredLang(requested, prior i18n.Lang) i18n.Lang {
if requested != "" {
return requested
}
return prior
}
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
switch opts.Identity { switch opts.Identity {
case "bot-only": case "bot-only":
sm := core.StrictModeBot sm := core.StrictModeBot
@@ -380,23 +365,9 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.L
appConfig.StrictMode = &sm appConfig.StrictMode = &sm
appConfig.DefaultAs = core.AsUser appConfig.DefaultAs = core.AsUser
} }
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior) if opts.Lang != "" {
} appConfig.Lang = opts.Lang
// priorLang returns the language preference recorded in a previous config, or
// "" if there is none / the bytes don't parse. Reads from CurrentApp (or Apps[0]
// fallback) — scanning all apps for the first non-empty Lang would leak the
// wrong profile's preference into a re-bind when the workspace holds multiple
// named profiles and the active one disagrees with Apps[0].
func priorLang(previousConfigBytes []byte) i18n.Lang {
var multi core.MultiAppConfig
if json.Unmarshal(previousConfigBytes, &multi) != nil {
return ""
} }
if app := multi.CurrentAppConfig(""); app != nil {
return app.Lang
}
return ""
} }
// commitBinding finalizes the bind: atomic write of the new workspace config, // commitBinding finalizes the bind: atomic write of the new workspace config,
@@ -408,21 +379,21 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}} multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil { if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "bind",
"failed to create workspace directory: %v", err)
} }
data, err := json.MarshalIndent(multi, "", " ") data, err := json.MarshalIndent(multi, "", " ")
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "bind",
"failed to marshal config: %v", err)
} }
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil { if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err) return output.Errorf(output.ExitInternal, "bind",
"failed to write config %s: %v", configPath, err)
} }
replaced := previousConfigBytes != nil replaced := previousConfigBytes != nil
// uiMsg renders human-facing TUI text (stderr success banner). Follows msg := getBindMsg(opts.Lang)
// opts.UILang — zh by default; picker can flip it to en. --lang does
// not influence the TUI language.
uiMsg := getBindMsg(opts.UILang)
display := sourceDisplayName(source) display := sourceDisplayName(source)
if replaced { if replaced {
@@ -430,11 +401,7 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
} }
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice) fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice)
if opts.langExplicit && opts.Lang != "" {
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(uiMsg.LangPreferenceSet, opts.Lang))
}
// TUI mode is a human sitting at a terminal; the BindSuccess notice on // TUI mode is a human sitting at a terminal; the BindSuccess notice on
// stderr is enough and a machine-readable JSON dump on stdout is just // stderr is enough and a machine-readable JSON dump on stdout is just
@@ -452,17 +419,12 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
"replaced": replaced, "replaced": replaced,
"identity": opts.Identity, "identity": opts.Identity,
} }
// JSON "message" follows the effective preference on disk (appConfig.Lang), brand := brandDisplay(string(appConfig.Brand), opts.Lang)
// not the raw --lang value: when --lang is omitted on re-bind, preferredLang
// has already inherited the prior preference into appConfig.Lang, and the
// message should respect that inherited choice. stderr above follows UILang.
prefMsg := getBindMsg(appConfig.Lang)
brand := brandDisplay(string(appConfig.Brand), appConfig.Lang)
switch opts.Identity { switch opts.Identity {
case "bot-only": case "bot-only":
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand) envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand)
case "user-default": case "user-default":
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display) envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display)
} }
resultJSON, _ := json.Marshal(envelope) resultJSON, _ := json.Marshal(envelope)
@@ -499,7 +461,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core
// tuiSelectSource prompts user to choose bind source. // tuiSelectSource prompts user to choose bind source.
func tuiSelectSource(opts *BindOptions) (string, error) { func tuiSelectSource(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.UILang) msg := getBindMsg(opts.Lang)
var source string var source string
// Pre-select based on detected env signals // Pre-select based on detected env signals
@@ -524,7 +486,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
huh.NewGroup( huh.NewGroup(
huh.NewSelect[string](). huh.NewSelect[string]().
Title(msg.SelectSource). Title(msg.SelectSource).
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))). Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))).
Options( Options(
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"), huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"), huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
@@ -546,7 +508,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
// tuiSelectApp prompts the user to choose from multiple account candidates. // tuiSelectApp prompts the user to choose from multiple account candidates.
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode. // Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) { func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
msg := getBindMsg(opts.UILang) msg := getBindMsg(opts.Lang)
options := make([]huh.Option[int], 0, len(candidates)) options := make([]huh.Option[int], 0, len(candidates))
for i, c := range candidates { for i, c := range candidates {
label := c.AppID label := c.AppID
@@ -560,7 +522,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
form := huh.NewForm( form := huh.NewForm(
huh.NewGroup( huh.NewGroup(
huh.NewSelect[int](). huh.NewSelect[int]().
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.UILang))). Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))).
Options(options...). Options(options...).
Value(&selected), Value(&selected),
), ),
@@ -577,7 +539,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel. // tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) { func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
msg := getBindMsg(opts.UILang) msg := getBindMsg(opts.Lang)
// Build existing binding summary // Build existing binding summary
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath) existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
@@ -626,14 +588,9 @@ func validateBindFlags(opts *BindOptions) error {
switch opts.Identity { switch opts.Identity {
case "bot-only", "user-default": case "bot-only", "user-default":
default: default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity") return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity)
} }
} }
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
}
opts.Lang = string(lang)
return nil return nil
} }
@@ -649,8 +606,8 @@ func validateBindFlags(opts *BindOptions) error {
// DescriptionFunc approach breaks here because a longer description on // DescriptionFunc approach breaks here because a longer description on
// hover pushes options out of the field's initial viewport. // hover pushes options out of the field's initial viewport.
func tuiSelectIdentity(opts *BindOptions) (string, error) { func tuiSelectIdentity(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.UILang) msg := getBindMsg(opts.Lang)
brand := brandDisplay(opts.Brand, opts.UILang) brand := brandDisplay(opts.Brand, opts.Lang)
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand)) botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand)) userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
var value string var value string

View File

@@ -3,8 +3,6 @@
package config package config
import "github.com/larksuite/cli/internal/i18n"
// bindMsg holds all TUI text for config bind, supporting zh/en via --lang. // bindMsg holds all TUI text for config bind, supporting zh/en via --lang.
// //
// Brand-aware strings use a %s slot where the UI-friendly product name // Brand-aware strings use a %s slot where the UI-friendly product name
@@ -86,11 +84,6 @@ type bindMsg struct {
// require in-flow human confirmation. // require in-flow human confirmation.
IdentityEscalationMessage string IdentityEscalationMessage string
IdentityEscalationHint string IdentityEscalationHint string
// LangPreferenceSet is printed to stderr after a successful bind when the
// user explicitly passed --lang. Format: language code. Not printed when
// --lang was not explicit (i.e., the cobra default zh stayed in effect).
LangPreferenceSet string
} }
var bindMsgZh = &bindMsg{ var bindMsgZh = &bindMsg{
@@ -123,8 +116,6 @@ var bindMsgZh = &bindMsg{
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。", IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`", IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
LangPreferenceSet: "语言偏好已设置:%s",
} }
var bindMsgEn = &bindMsg{ var bindMsgEn = &bindMsg{
@@ -159,13 +150,10 @@ var bindMsgEn = &bindMsg{
IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.", IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.",
IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`", IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`",
LangPreferenceSet: "Language preference set to: %s",
} }
// getBindMsg picks the zh/en TUI bundle; non-English falls back to zh. func getBindMsg(lang string) *bindMsg {
func getBindMsg(lang i18n.Lang) *bindMsg { if lang == "en" {
if lang.IsEnglish() {
return bindMsgEn return bindMsgEn
} }
return bindMsgZh return bindMsgZh
@@ -176,11 +164,11 @@ func getBindMsg(lang i18n.Lang) *bindMsg {
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en — // "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en —
// this is the safe default when the brand hasn't been resolved yet (for // this is the safe default when the brand hasn't been resolved yet (for
// example, on the pre-binding source-selection screen). // example, on the pre-binding source-selection screen).
func brandDisplay(brand string, lang i18n.Lang) string { func brandDisplay(brand, lang string) string {
if brand == "lark" || brand == "Lark" || brand == "LARK" { if brand == "lark" || brand == "Lark" || brand == "LARK" {
return "Lark" return "Lark"
} }
if lang.IsEnglish() { if lang == "en" {
return "Feishu" return "Feishu"
} }
return "飞书" return "飞书"

View File

@@ -16,50 +16,42 @@ import (
"github.com/larksuite/cli/errs" "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/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 validation error — they normalize to the same wantDetail fields.
type wantErrDetail struct { func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
Type string
Message string
Hint string
}
// assertExitError checks the full structured error in one assertion against a
// typed error (ValidationError or ConfigError), normalizing its Category /
// Message / Hint to wantDetail.
func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDetail) {
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)
} }
return return
} }
var ce *errs.ConfigError t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError; error = %v", err, err)
if errors.As(err, &ce) {
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := wantErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
return
}
t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
} }
// assertEnvelope decodes stdout and checks it matches want exactly — every key // assertEnvelope decodes stdout and checks it matches want exactly — every key
@@ -128,235 +120,14 @@ func TestConfigBindCmd_LangDefault(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)
} }
if gotOpts.Lang != "" { if gotOpts.Lang != "zh" {
t.Errorf("Lang = %q, want default %q (unset)", gotOpts.Lang, "") t.Errorf("Lang = %q, want default %q", gotOpts.Lang, "zh")
} }
if gotOpts.langExplicit { if gotOpts.langExplicit {
t.Error("expected langExplicit=false when --lang not passed") t.Error("expected langExplicit=false when --lang not passed")
} }
} }
// TestConfigBindRun_InvalidLang verifies a non-empty --lang is strictly
// validated: wrong case, typos, and removed codes all exit with
// ExitValidation (code 2) and a message identifying the offending value.
// (Empty is not invalid — see TestConfigBindRun_EmptyLangIsNoOp.)
func TestConfigBindRun_InvalidLang(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
cases := []struct {
name string
lang string
}{
{"wrong case ZH", "ZH"},
{"typo frr", "frr"},
{"removed code ar", "ar"},
{"unknown xx", "xx"},
{"hyphen form zh-CN", "zh-CN"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Lang: tc.lang,
langExplicit: true,
})
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
}
})
}
}
// TestConfigBindRun_EmptyLangIsNoOp verifies that an empty --lang (omitted or
// explicit "") is unset: it neither errors nor persists a language, while a
// non-empty short code or Feishu locale both canonicalize to the same locale.
func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) {
cases := []struct {
name string
lang string
explicit bool
wantLang i18n.Lang
}{
{"omitted", "", false, ""},
{"explicit empty", "", true, ""},
{"short code", "ja", true, i18n.LangJaJP},
{"feishu locale", "ja_jp", true, i18n.LangJaJP},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Lang: tc.lang,
langExplicit: tc.explicit,
}); err != nil {
t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
app := multi.CurrentAppConfig("")
if app == nil {
t.Fatal("no app persisted")
}
if app.Lang != tc.wantLang {
t.Errorf("persisted Lang = %q, want %q", app.Lang, tc.wantLang)
}
})
}
}
// TestConfigBindRun_OmitLangPreservesPrior guards against a re-bind without
// --lang silently dropping a previously stored preference (appConfig is rebuilt
// fresh, so commitBinding must inherit the prior Lang).
func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f1, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "ja", langExplicit: true}); err != nil {
t.Fatalf("first bind (--lang ja): %v", err)
}
f2, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
t.Fatalf("re-bind (no --lang): %v", err)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
if app := multi.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("Lang after re-bind = %v, want %q (preserved)", app, i18n.LangJaJP)
}
}
// TestPriorLang_RespectsCurrentApp guards against priorLang scanning all apps
// and silently returning a non-current profile's Lang. In a multi-profile
// workspace (set up via `profile add` before a re-bind), the active profile's
// Lang must win over a sibling profile that happens to sit earlier in the slice.
func TestPriorLang_RespectsCurrentApp(t *testing.T) {
multi := core.MultiAppConfig{
CurrentApp: "active",
Apps: []core.AppConfig{
{Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP},
{Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS},
},
}
bytes, err := json.Marshal(multi)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if got := priorLang(bytes); got != i18n.LangEnUS {
t.Errorf("priorLang = %q, want %q (must follow CurrentApp, not Apps[0])", got, i18n.LangEnUS)
}
}
// TestPriorLang_FallsBackToFirstAppWhenCurrentUnset covers the legacy
// single-app shape (no CurrentApp): CurrentAppConfig falls back to Apps[0],
// so a bind-written config (which always has exactly one app and no
// CurrentApp field) still inherits its Lang.
func TestPriorLang_FallsBackToFirstAppWhenCurrentUnset(t *testing.T) {
multi := core.MultiAppConfig{
Apps: []core.AppConfig{
{AppId: "cli_only", Lang: i18n.LangJaJP},
},
}
bytes, err := json.Marshal(multi)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if got := priorLang(bytes); got != i18n.LangJaJP {
t.Errorf("priorLang = %q, want %q", got, i18n.LangJaJP)
}
}
// TestPriorLang_MalformedReturnsEmpty exercises the unparseable-bytes branch.
func TestPriorLang_MalformedReturnsEmpty(t *testing.T) {
if got := priorLang([]byte("not json")); got != "" {
t.Errorf("priorLang(malformed) = %q, want \"\"", got)
}
}
// TestConfigBindRun_EnvelopeMessageFollowsInheritedLang guards the JSON envelope
// "message" field against regressing to opts.Lang: when --lang is omitted on
// re-bind, the inherited preference (appConfig.Lang) must drive the message
// language and the embedded brand display — otherwise an AI agent that set
// English on first bind sees Chinese in every subsequent re-bind envelope.
func TestConfigBindRun_EnvelopeMessageFollowsInheritedLang(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f1, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "en", langExplicit: true}); err != nil {
t.Fatalf("first bind (--lang en): %v", err)
}
f2, stdout, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
t.Fatalf("re-bind (no --lang): %v", err)
}
envelope := map[string]any{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
msg, _ := envelope["message"].(string)
enMsg := getBindMsg(i18n.LangEnUS)
wantMsg := fmt.Sprintf(enMsg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", i18n.LangEnUS))
if msg != wantMsg {
t.Errorf("envelope.message = %q,\nwant %q (must follow inherited appConfig.Lang=en_us, not raw opts.Lang)", msg, wantMsg)
}
}
// ── Run function tests (aligned with TestConfigShowRun pattern) ── // ── Run function tests (aligned with TestConfigShowRun pattern) ──
func TestConfigBindRun_InvalidSource(t *testing.T) { func TestConfigBindRun_InvalidSource(t *testing.T) {
@@ -365,7 +136,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,8 +153,8 @@ 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: "bind",
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,8 +192,8 @@ 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: "bind",
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,8 +208,8 @@ 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: "bind",
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,8 +337,8 @@ 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.ExitValidation, output.ErrDetail{
Type: "config", Type: "hermes",
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,8 +355,8 @@ 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.ExitValidation, output.ErrDetail{
Type: "config", Type: "openclaw",
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,8 +502,8 @@ 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: "bind",
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,8 +521,8 @@ 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.ExitValidation, output.ErrDetail{
Type: "config", Type: "lark-channel",
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,8 +541,8 @@ 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.ExitValidation, output.ErrDetail{
Type: "config", Type: "lark-channel",
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,8 +560,8 @@ 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.ExitValidation, output.ErrDetail{
Type: "config", Type: "lark-channel",
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 +606,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)
@@ -1143,8 +912,12 @@ func TestConfigBindRun_OpenClawMultiAccount_MissingAppID(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error for multi-account without --app-id, got nil") t.Fatal("expected error for multi-account without --app-id, got nil")
} }
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { var exitErr *output.ExitError
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
} }
} }
@@ -1189,8 +962,8 @@ 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: "openclaw",
Message: "multiple accounts in openclaw.json; pass --app-id <id>", Message: "multiple accounts in openclaw.json; pass --app-id <id>",
} }
wantWorkFirst := base wantWorkFirst := base
@@ -1198,17 +971,20 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
wantPersonalFirst := base wantPersonalFirst := base
wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)" wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)"
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { var exitErr *output.ExitError
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; err = %v", err, err)
} }
var ve *errs.ValidationError if exitErr.Code != output.ExitValidation {
if !errors.As(err, &ve) { t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
} }
got := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint} if exitErr.Detail == nil {
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) { t.Fatal("expected non-nil error detail")
}
if !reflect.DeepEqual(*exitErr.Detail, wantWorkFirst) &&
!reflect.DeepEqual(*exitErr.Detail, wantPersonalFirst) {
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v", t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
got, wantWorkFirst, wantPersonalFirst) *exitErr.Detail, wantWorkFirst, wantPersonalFirst)
} }
} }
@@ -1232,8 +1008,8 @@ 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: "openclaw",
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 +1028,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`,
}) })
@@ -1365,19 +1141,11 @@ func TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce(t *testing.T) {
Identity: "user-default", Identity: "user-default",
}) })
msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default
var ce *errs.ConfirmationRequiredError assertExitError(t, err, output.ExitValidation, output.ErrDetail{
if !errors.As(err, &ce) { Type: "bind",
t.Fatalf("error type = %T, want *errs.ConfirmationRequiredError; error = %v", err, err) Message: msg.IdentityEscalationMessage,
} Hint: msg.IdentityEscalationHint,
if ce.Risk != errs.RiskHighRiskWrite { })
t.Errorf("Risk = %q, want %q", ce.Risk, errs.RiskHighRiskWrite)
}
if ce.Message != msg.IdentityEscalationMessage {
t.Errorf("Message mismatch:\ngot: %q\nwant: %q", ce.Message, msg.IdentityEscalationMessage)
}
if ce.Hint != msg.IdentityEscalationHint {
t.Errorf("Hint mismatch:\ngot: %q\nwant: %q", ce.Hint, msg.IdentityEscalationHint)
}
// Config on disk must remain untouched — the gate runs before // Config on disk must remain untouched — the gate runs before
// commitBinding writes anything. // commitBinding writes anything.
@@ -1538,8 +1306,8 @@ 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.ExitValidation, output.ErrDetail{
Type: "config", Type: "hermes",
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,8 +1326,8 @@ 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.ExitValidation, output.ErrDetail{
Type: "config", Type: "hermes",
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,8 +1352,8 @@ 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.ExitValidation, output.ErrDetail{
Type: "config", Type: "openclaw",
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,8 +1380,8 @@ 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.ExitValidation, output.ErrDetail{
Type: "config", Type: "openclaw",
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,8 +1442,8 @@ 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.ExitValidation, output.ErrDetail{
Type: "config", Type: "openclaw",
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",
}) })
@@ -1706,14 +1474,10 @@ func TestGetBindMsg_En(t *testing.T) {
} }
} }
func TestGetBindMsg_NonEnLang_FallsBackToZh(t *testing.T) { func TestGetBindMsg_UnknownLang_DefaultsToZh(t *testing.T) {
// Only zh and en TUI bundles exist; any non-English language (canonical msg := getBindMsg("fr")
// locale, short code, or unrecognized value) falls back to zh. if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
for _, lang := range []i18n.Lang{"fr_fr", "ja_jp", "ko", "unknown", ""} { t.Errorf("fr (default) SelectSource = %q, want %q", msg.SelectSource, want)
msg := getBindMsg(lang)
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
t.Errorf("getBindMsg(%q) SelectSource = %q, want %q (zh fallback)", lang, msg.SelectSource, want)
}
} }
} }
@@ -1876,36 +1640,3 @@ func TestHasStrictBotLock(t *testing.T) {
}) })
} }
} }
// TestConfigBindRun_LangExplicit_PrintsConfirmation covers the flag-mode
// confirmation line: when --lang is explicit, bind prints "language preference
// set" to stderr (rendered in the TUI language, embedding the preference value).
func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Identity: "bot-only",
Lang: "en",
langExplicit: true,
})
if err != nil {
t.Fatalf("expected success, got error: %v", err)
}
// The short --lang en is canonicalized to en_us before the confirmation
// echoes it back; the TUI language stays zh (flag mode, no picker).
want := fmt.Sprintf(getBindMsg(i18n.LangZhCN).LangPreferenceSet, "en_us")
if got := stderr.String(); !strings.Contains(got, want) {
t.Errorf("stderr = %q, want it to contain confirmation %q", got, want)
}
}

View File

@@ -9,9 +9,9 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/binding" "github.com/larksuite/cli/internal/binding"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/internal/vfs"
) )
@@ -49,7 +49,7 @@ func newBinder(source string, opts *BindOptions) (SourceBinder, error) {
case "lark-channel": case "lark-channel":
return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil
default: default:
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported source: %s", source).WithParam("--source") return nil, output.ErrValidation("unsupported source: %s", source)
} }
} }
@@ -85,10 +85,11 @@ func selectCandidate(
// from ListCandidates itself and never reach here. // from ListCandidates itself and never reach here.
switch src { switch src {
case "openclaw": case "openclaw":
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "no Feishu app configured in openclaw.json"). return nil, output.ErrWithHint(output.ExitValidation, src,
WithHint("configure channels.feishu.appId in openclaw.json") "no Feishu app configured in openclaw.json",
"configure channels.feishu.appId in openclaw.json")
default: default:
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "%s: no app configured", src) return nil, output.ErrValidation("%s: no app configured", src)
} }
} }
@@ -98,9 +99,9 @@ func selectCandidate(
return &candidates[i], nil return &candidates[i], nil
} }
} }
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id %q not found in %s", appIDFlag, cfgBase). return nil, output.ErrWithHint(output.ExitValidation, src,
WithHint("available app IDs:\n %s", formatCandidates(candidates)). fmt.Sprintf("--app-id %q not found in %s", appIDFlag, cfgBase),
WithParam("--app-id") fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
} }
if len(candidates) == 1 { if len(candidates) == 1 {
@@ -111,9 +112,9 @@ func selectCandidate(
return tuiPrompt(candidates) return tuiPrompt(candidates)
} }
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "multiple accounts in %s; pass --app-id <id>", cfgBase). return nil, output.ErrWithHint(output.ExitValidation, src,
WithHint("available app IDs:\n %s", formatCandidates(candidates)). fmt.Sprintf("multiple accounts in %s; pass --app-id <id>", cfgBase),
WithParam("--app-id") fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
} }
// formatCandidates renders candidates as "AppID (Label)" lines for error hints. // formatCandidates renders candidates as "AppID (Label)" lines for error hints.
@@ -148,13 +149,14 @@ func (b *openclawBinder) ConfigPath() string { return b.path }
func (b *openclawBinder) ListCandidates() ([]Candidate, error) { func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
cfg, err := binding.ReadOpenClawConfig(b.path) cfg, err := binding.ReadOpenClawConfig(b.path)
if err != nil { if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err). return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
WithHint("verify OpenClaw is installed and configured"). fmt.Sprintf("cannot read %s: %v", b.path, err),
WithCause(err) "verify OpenClaw is installed and configured")
} }
if cfg.Channels.Feishu == nil { if cfg.Channels.Feishu == nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "openclaw.json missing channels.feishu section"). return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
WithHint("configure Feishu in OpenClaw first") "openclaw.json missing channels.feishu section",
"configure Feishu in OpenClaw first")
} }
raw := binding.ListCandidateApps(cfg.Channels.Feishu) raw := binding.ListCandidateApps(cfg.Channels.Feishu)
@@ -170,7 +172,8 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) { func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil { if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") return nil, output.Errorf(output.ExitInternal, "openclaw",
"internal: Build called before ListCandidates")
} }
var selected *binding.CandidateApp var selected *binding.CandidateApp
@@ -181,25 +184,26 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
} }
} }
if selected == nil { if selected == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID) return nil, output.Errorf(output.ExitInternal, "openclaw",
"internal: appID %q not in candidates", appID)
} }
if selected.AppSecret.IsZero() { if selected.AppSecret.IsZero() {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path). return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
WithHint("configure channels.feishu.appSecret in openclaw.json") fmt.Sprintf("appSecret is empty for app %s in %s", selected.AppID, b.path),
"configure channels.feishu.appSecret in openclaw.json")
} }
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv) secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
if err != nil { if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err). return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
WithHint("check appSecret configuration in %s", b.path). fmt.Sprintf("failed to resolve appSecret for %s: %v", selected.AppID, err),
WithCause(err) fmt.Sprintf("check appSecret configuration in %s", b.path))
} }
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain) stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil { if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). return nil, output.Errorf(output.ExitInternal, "openclaw",
WithHint("use file: reference in config to bypass keychain"). "keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
WithCause(err)
} }
return &core.AppConfig{ return &core.AppConfig{
@@ -225,14 +229,15 @@ func (b *hermesBinder) ConfigPath() string { return b.path }
func (b *hermesBinder) ListCandidates() ([]Candidate, error) { func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
envMap, err := readDotenv(b.path) envMap, err := readDotenv(b.path)
if err != nil { if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to read Hermes config: %v", err). return nil, output.ErrWithHint(output.ExitValidation, "hermes",
WithHint("verify Hermes is installed and configured at %s", b.path). fmt.Sprintf("failed to read Hermes config: %v", err),
WithCause(err) fmt.Sprintf("verify Hermes is installed and configured at %s", b.path))
} }
appID := envMap["FEISHU_APP_ID"] appID := envMap["FEISHU_APP_ID"]
if appID == "" { if appID == "" {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "FEISHU_APP_ID not found in %s", b.path). return nil, output.ErrWithHint(output.ExitValidation, "hermes",
WithHint("run 'hermes setup' to configure Feishu credentials") fmt.Sprintf("FEISHU_APP_ID not found in %s", b.path),
"run 'hermes setup' to configure Feishu credentials")
} }
b.envMap = envMap b.envMap = envMap
return []Candidate{{AppID: appID, Label: "default"}}, nil return []Candidate{{AppID: appID, Label: "default"}}, nil
@@ -240,22 +245,24 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) { func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
if b.envMap == nil { if b.envMap == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") return nil, output.Errorf(output.ExitInternal, "hermes",
"internal: Build called before ListCandidates")
} }
if b.envMap["FEISHU_APP_ID"] != appID { if b.envMap["FEISHU_APP_ID"] != appID {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match env", appID) return nil, output.Errorf(output.ExitInternal, "hermes",
"internal: appID %q does not match env", appID)
} }
appSecret := b.envMap["FEISHU_APP_SECRET"] appSecret := b.envMap["FEISHU_APP_SECRET"]
if appSecret == "" { if appSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "FEISHU_APP_SECRET not found in %s", b.path). return nil, output.ErrWithHint(output.ExitValidation, "hermes",
WithHint("run 'hermes setup' to configure Feishu credentials") fmt.Sprintf("FEISHU_APP_SECRET not found in %s", b.path),
"run 'hermes setup' to configure Feishu credentials")
} }
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain) stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
if err != nil { if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). return nil, output.Errorf(output.ExitInternal, "hermes",
WithHint("use file: reference in config to bypass keychain"). "keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
WithCause(err)
} }
return &core.AppConfig{ return &core.AppConfig{
@@ -283,13 +290,14 @@ func (b *larkChannelBinder) ConfigPath() string { return b.path }
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) { func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
cfg, err := binding.ReadLarkChannelConfig(b.path) cfg, err := binding.ReadLarkChannelConfig(b.path)
if err != nil { if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err). return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
WithHint("verify lark-channel-bridge is installed and configured"). fmt.Sprintf("cannot read %s: %v", b.path, err),
WithCause(err) "verify lark-channel-bridge is installed and configured")
} }
if cfg.Accounts.App.ID == "" { if cfg.Accounts.App.ID == "" {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "accounts.app.id missing in %s", b.path). return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
WithHint("run lark-channel-bridge's setup to populate the app credential") fmt.Sprintf("accounts.app.id missing in %s", b.path),
"run lark-channel-bridge's setup to populate the app credential")
} }
b.cfg = cfg b.cfg = cfg
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
@@ -297,30 +305,32 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) { func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil { if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") return nil, output.Errorf(output.ExitInternal, "lark-channel",
"internal: Build called before ListCandidates")
} }
if b.cfg.Accounts.App.ID != appID { if b.cfg.Accounts.App.ID != appID {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match config", appID) return nil, output.Errorf(output.ExitInternal, "lark-channel",
"internal: appID %q does not match config", appID)
} }
if b.cfg.Accounts.App.Secret.IsZero() { if b.cfg.Accounts.App.Secret.IsZero() {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "accounts.app.secret is empty in %s", b.path). return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
WithHint("run lark-channel-bridge's setup to populate the app credential") fmt.Sprintf("accounts.app.secret is empty in %s", b.path),
"run lark-channel-bridge's setup to populate the app credential")
} }
// Resolve through the same SecretInput pipeline openclaw uses, so // Resolve through the same SecretInput pipeline openclaw uses, so
// bridge configs can use ${VAR} / env / file / exec just like openclaw. // bridge configs can use ${VAR} / env / file / exec just like openclaw.
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv) secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
if err != nil { if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err). return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
WithHint("check appSecret configuration in %s", b.path). fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err),
WithCause(err) fmt.Sprintf("check appSecret configuration in %s", b.path))
} }
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain) stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil { if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). return nil, output.Errorf(output.ExitInternal, "lark-channel",
WithHint("use file: reference in config to bypass keychain"). "keychain unavailable: %v", err)
WithCause(err)
} }
return &core.AppConfig{ return &core.AppConfig{
@@ -379,12 +389,10 @@ func resolveHermesEnvPath() string {
} }
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's // resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
// source config. LARK_CHANNEL_CONFIG lets a host point bind at a projected // config.json. Mirrors the bridge's src/config/paths.ts which hardcodes
// single-account config without changing lark-cli's target config directory. // ~/.lark-channel/config.json with no env override — multi-instance is not
// a supported scenario today.
func resolveLarkChannelConfigPath() string { func resolveLarkChannelConfigPath() string {
if p := os.Getenv("LARK_CHANNEL_CONFIG"); strings.TrimSpace(p) != "" {
return expandHome(p)
}
home, err := vfs.UserHomeDir() home, err := vfs.UserHomeDir()
if err != nil || home == "" { if err != nil || home == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)

View File

@@ -4,7 +4,6 @@
package config package config
import ( import (
"path/filepath"
"reflect" "reflect"
"testing" "testing"
@@ -51,8 +50,8 @@ 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.ExitValidation, output.ErrDetail{
Type: "config", Type: "openclaw",
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,8 +63,8 @@ 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.ExitValidation, output.ErrDetail{
Type: "config", Type: "validation",
Message: "hermes: no app configured", Message: "hermes: no app configured",
}) })
} }
@@ -100,8 +99,8 @@ 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: "openclaw",
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,8 +116,8 @@ 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: "openclaw",
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,8 +151,8 @@ 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: "openclaw",
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",
}) })
@@ -174,27 +173,3 @@ func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
} }
assertCandidate(t, got, Candidate{AppID: "cli_b"}) assertCandidate(t, got, Candidate{AppID: "cli_b"})
} }
func TestResolveLarkChannelConfigPath_Default(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("LARK_CHANNEL_CONFIG", "")
got := resolveLarkChannelConfigPath()
want := filepath.Join(home, ".lark-channel", "config.json")
if got != want {
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
}
}
func TestResolveLarkChannelConfigPath_EnvOverride(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("LARK_CHANNEL_CONFIG", "~/bridge/projection.json")
got := resolveLarkChannelConfigPath()
want := filepath.Join(home, "bridge", "projection.json")
if got != want {
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
}
}

View File

@@ -33,7 +33,6 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigStrictMode(f)) cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigPolicy(f)) cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f)) cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
return cmd return cmd
} }

View File

@@ -12,12 +12,10 @@ 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"
"github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/credential"
"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/output"
) )
@@ -93,16 +91,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)
} }
} }
@@ -127,11 +125,15 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
t.Fatal("expected error") t.Fatal("expected error")
} }
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { var exitErr *output.ExitError
t.Errorf("exit code = %d, want %d", gotCode, output.ExitAuth) if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
} }
if !strings.Contains(err.Error(), "no active profile") { if exitErr.Code != output.ExitValidation {
t.Fatalf("error = %v, want to contain 'no active profile'", err) t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "no active profile" {
t.Fatalf("detail = %#v, want config/no active profile", exitErr.Detail)
} }
} }
@@ -149,9 +151,8 @@ func TestConfigInitCmd_LangFlag(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)
} }
// --lang en is canonicalized to en_us in RunE before runF captures opts. if gotOpts.Lang != "en" {
if gotOpts.Lang != string(i18n.LangEnUS) { t.Errorf("expected Lang en, got %s", gotOpts.Lang)
t.Errorf("expected Lang en_us, got %s", gotOpts.Lang)
} }
if !gotOpts.langExplicit { if !gotOpts.langExplicit {
t.Error("expected langExplicit=true when --lang is passed") t.Error("expected langExplicit=true when --lang is passed")
@@ -172,88 +173,14 @@ func TestConfigInitCmd_LangDefault(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)
} }
if gotOpts.Lang != "" { if gotOpts.Lang != "zh" {
t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang) t.Errorf("expected default Lang zh, got %s", gotOpts.Lang)
} }
if gotOpts.langExplicit { if gotOpts.langExplicit {
t.Error("expected langExplicit=false when --lang is not passed") t.Error("expected langExplicit=false when --lang is not passed")
} }
} }
// TestSaveInitConfig_OmitLangPreservesPrior guards the single-app replace path:
// re-running init without --lang must inherit the prior preference, not clear it.
func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
existing := &core.MultiAppConfig{Apps: []core.AppConfig{
{AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP},
}}
if err := core.SaveMultiAppConfig(existing); err != nil {
t.Fatalf("seed config: %v", err)
}
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
t.Fatalf("saveInitConfig (no --lang): %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
if app := got.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("Lang after re-init = %v, want %q (preserved)", app, i18n.LangJaJP)
}
}
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
// strictly validated the same way bind validates: wrong-case / typo / removed
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
func TestConfigInitCmd_InvalidLang(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
lang string
}{
{"wrong case ZH", "ZH"},
{"typo frr", "frr"},
{"removed code ar", "ar"},
{"unknown xx", "xx"},
{"hyphen form zh-CN", "zh-CN"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigInit(f, nil)
f.IOStreams.In = strings.NewReader("sec\n")
cmd.SetArgs([]string{"--lang", tc.lang, "--app-id", "x", "--app-secret-stdin"})
err := cmd.Execute()
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
}
})
}
}
func TestHasAnyNonInteractiveFlag(t *testing.T) { func TestHasAnyNonInteractiveFlag(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -392,38 +319,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")
} }
} }
@@ -502,65 +399,16 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
if matched != nil && matched != cmd && !matched.SilenceUsage { if matched != nil && matched != cmd && !matched.SilenceUsage {
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand") t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
} }
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { var exitErr *output.ExitError
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
} }
}) })
} }
} }
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
// the same locale; an unrecognized value errors.
func TestValidateInitLang(t *testing.T) {
t.Run("empty is a no-op", func(t *testing.T) {
for _, explicit := range []bool{false, true} {
opts := &ConfigInitOptions{Lang: "", langExplicit: explicit}
if err := validateInitLang(opts); err != nil {
t.Fatalf("explicit=%v: expected nil error, got %v", explicit, err)
}
if opts.Lang != "" {
t.Errorf("explicit=%v: Lang = %q, want \"\" (unset)", explicit, opts.Lang)
}
}
})
t.Run("short and locale canonicalize alike", func(t *testing.T) {
for _, in := range []string{"ja", "ja_jp"} {
opts := &ConfigInitOptions{Lang: in, langExplicit: true}
if err := validateInitLang(opts); err != nil {
t.Fatalf("--lang %q: unexpected error %v", in, err)
}
if opts.Lang != string(i18n.LangJaJP) {
t.Errorf("--lang %q normalized to %q, want %q", in, opts.Lang, i18n.LangJaJP)
}
}
})
}
// TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints
// to stderr only when --lang explicitly set a non-empty preference.
func TestPrintLangPreferenceConfirmation(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Run("explicit non-empty prints confirmation", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: true})
got := stderr.String()
if !strings.Contains(got, "语言偏好") || !strings.Contains(got, "en_us") {
t.Errorf("stderr = %q, want confirmation mentioning the preference and en_us", got)
}
})
t.Run("implicit prints nothing", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: false})
if got := stderr.String(); got != "" {
t.Errorf("stderr = %q, want empty when --lang is implicit", got)
}
})
t.Run("explicit empty prints nothing", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "", UILang: i18n.LangZhCN, langExplicit: true})
if got := stderr.String(); got != "" {
t.Errorf("stderr = %q, want empty when --lang is empty", got)
}
})
}

View File

@@ -6,9 +6,9 @@ package config
import ( import (
"fmt" "fmt"
"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/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -41,12 +41,12 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
value := args[0] value := args[0]
if value != "user" && value != "bot" && value != "auto" { if value != "user" && value != "bot" && value != "auto" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value) return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value)
} }
app.DefaultAs = core.Identity(value) app.DefaultAs = core.Identity(value)
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)
} }
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value) fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
return nil return nil

View File

@@ -6,18 +6,18 @@ 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/internal/auth" "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/i18n"
"github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
) )
@@ -31,13 +31,9 @@ type ConfigInitOptions struct {
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure) AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
Brand string Brand string
New bool New bool
Lang string
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang langExplicit bool // true when --lang was explicitly passed
langExplicit bool // true when --lang was explicitly passed ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
// ForceInit overrides the agent-workspace guard. Without it, running // ForceInit overrides the agent-workspace guard. Without it, running
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller // init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
@@ -49,7 +45,7 @@ type ConfigInitOptions struct {
// NewCmdConfigInit creates the config init subcommand. // NewCmdConfigInit creates the config init subcommand.
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command { func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN} opts := &ConfigInitOptions{Factory: f}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "init", Use: "init",
@@ -67,9 +63,6 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context() opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang") opts.langExplicit = cmd.Flags().Changed("lang")
if err := validateInitLang(opts); err != nil {
return err
}
if err := guardAgentWorkspace(opts); err != nil { if err := guardAgentWorkspace(opts); err != nil {
return err return err
} }
@@ -84,7 +77,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)") cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure") cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)") cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)") cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)") cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app") cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
cmdutil.SetRisk(cmd, "write") cmdutil.SetRisk(cmd, "write")
@@ -92,25 +85,6 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
return cmd return cmd
} }
// printLangPreferenceConfirmation echoes the set preference to stderr, only
// when --lang explicitly set a non-empty value.
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
if !opts.langExplicit || opts.Lang == "" {
return
}
msg := getInitMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, opts.Lang))
}
func validateInitLang(opts *ConfigInitOptions) error {
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
}
opts.Lang = string(lang)
return nil
}
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or // guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
// Hermes Agent context, because the Agent has already provisioned an app // Hermes Agent context, because the Agent has already provisioned an app
// and 'config bind' is the right tool for hooking lark-cli into it. // and 'config bind' is the right tool for hooking lark-cli into it.
@@ -125,9 +99,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.
@@ -155,7 +132,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
config := &core.MultiAppConfig{ config := &core.MultiAppConfig{
Apps: []core.AppConfig{{ Apps: []core.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{}, AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{},
}}, }},
} }
return core.SaveMultiAppConfig(config) return core.SaveMultiAppConfig(config)
@@ -169,27 +146,7 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang) return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
} }
cleanupOldConfig(existing, f, appId) cleanupOldConfig(existing, f, appId)
var prior i18n.Lang return saveAsOnlyApp(appId, secret, brand, lang)
if existing != nil {
if app := existing.CurrentAppConfig(""); app != nil {
prior = app.Lang
}
}
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
}
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
// validation error from saveAsProfile) through unchanged, and classifies any
// other failure as an internal storage error. Without the passthrough a user
// input error would surface to agents as a system storage failure.
func wrapSaveConfigError(err error) error {
if err == nil {
return nil
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
} }
// saveAsProfile appends or updates a named profile in the config. // saveAsProfile appends or updates a named profile in the config.
@@ -210,15 +167,14 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
} }
multi.Apps[idx].Users = []core.AppUser{} multi.Apps[idx].Users = []core.AppUser{}
} }
// Update existing profile
multi.Apps[idx].AppId = appId multi.Apps[idx].AppId = appId
multi.Apps[idx].AppSecret = secret multi.Apps[idx].AppSecret = secret
multi.Apps[idx].Brand = brand multi.Apps[idx].Brand = brand
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang) multi.Apps[idx].Lang = 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{
@@ -226,7 +182,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
AppId: appId, AppId: appId,
AppSecret: secret, AppSecret: secret,
Brand: brand, Brand: brand,
Lang: i18n.Lang(lang), Lang: lang,
Users: []core.AppUser{}, Users: []core.AppUser{},
}) })
} }
@@ -257,25 +213,9 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
return -1 return -1
} }
// wrapUpdateExistingProfileErr classifies the error returned by
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
// for blank-input) pass through unchanged so their exit code semantics
// survive; everything else (filesystem, keychain, etc.) is wrapped as
// InternalError.
func wrapUpdateExistingProfileErr(err error) error {
if err == nil {
return nil
}
if errs.IsTyped(err) {
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
}
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error { func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
if existing == nil { if existing == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration"). return output.ErrValidation("App Secret cannot be empty for new configuration")
WithParam("--app-secret")
} }
var app *core.AppConfig var app *core.AppConfig
@@ -283,25 +223,22 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
if idx := findProfileIndexByName(existing, profileName); idx >= 0 { if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
app = &existing.Apps[idx] app = &existing.Apps[idx]
} else { } else {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile"). return output.ErrValidation("App Secret cannot be empty for new profile")
WithParam("--app-secret")
} }
} else { } else {
app = existing.CurrentAppConfig("") app = existing.CurrentAppConfig("")
if app == nil { if app == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration"). return output.ErrValidation("App Secret cannot be empty for new configuration")
WithParam("--app-secret")
} }
} }
if app.AppId != appID { if app.AppId != appID {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty when changing App ID"). return output.ErrValidation("App Secret cannot be empty when changing App ID")
WithParam("--app-secret")
} }
app.AppId = appID app.AppId = appID
app.Brand = brand app.Brand = brand
app.Lang = preferredLang(i18n.Lang(lang), app.Lang) app.Lang = lang
return core.SaveMultiAppConfig(existing) return core.SaveMultiAppConfig(existing)
} }
@@ -313,13 +250,13 @@ func configInitRun(opts *ConfigInitOptions) error {
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.SubtypeInvalidArgument, "failed to read secret from stdin: %v", err).WithCause(err) return output.ErrValidation("failed to read secret from stdin: %v", err)
} }
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret") return output.ErrValidation("stdin is empty, expected app secret")
} }
opts.appSecret = strings.TrimSpace(scanner.Text()) opts.appSecret = strings.TrimSpace(scanner.Text())
if opts.appSecret == "" { if opts.appSecret == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty") return output.ErrValidation("app secret read from stdin is empty")
} }
} }
@@ -331,7 +268,7 @@ func configInitRun(opts *ConfigInitOptions) error {
// Validate --profile name if set // Validate --profile name if set
if opts.ProfileName != "" { if opts.ProfileName != "" {
if err := core.ValidateProfileName(opts.ProfileName); err != nil { if err := core.ValidateProfileName(opts.ProfileName); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err) return output.ErrValidation("%v", err)
} }
} }
@@ -340,33 +277,35 @@ func configInitRun(opts *ConfigInitOptions) error {
brand := parseBrand(opts.Brand) brand := parseBrand(opts.Brand)
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain) secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "%v", err)
} }
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil { if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return wrapSaveConfigError(err) return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
} }
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand}) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
return err
}
return nil return nil
} }
// For interactive modes, prompt language selection if --lang was not explicitly set. // For interactive modes, prompt language selection if --lang was not explicitly set
// Picker offers 2 options (中文 / English) and drives BOTH opts.Lang
// (preference) and opts.UILang (TUI rendering).
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() { if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
lang, err := promptLangSelection() savedLang := ""
if err != nil { if existing != nil {
return langSelectionError(err) if app := existing.CurrentAppConfig(""); app != nil {
savedLang = app.Lang
}
} }
opts.Lang = string(lang) lang, err := promptLangSelection(savedLang)
opts.UILang = lang if err != nil {
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return err
}
opts.Lang = lang
} }
msg := getInitMsg(opts.UILang) msg := getInitMsg(opts.Lang)
// Mode 3: Create new app directly (--new) // Mode 3: Create new app directly (--new)
if opts.New { if opts.New {
@@ -375,21 +314,17 @@ func configInitRun(opts *ConfigInitOptions) error {
return err return err
} }
if result == nil { if result == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result") return output.ErrValidation("app creation returned no result")
} }
existing, _ := core.LoadMultiAppConfig() existing, _ := core.LoadMultiAppConfig()
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "%v", err)
} }
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err) return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
} }
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
return nil return nil
} }
@@ -400,8 +335,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return err return err
} }
if result == nil { if result == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty"). return output.ErrValidation("App ID and App Secret cannot be empty")
WithParam("--app-id")
} }
existing, _ := core.LoadMultiAppConfig() existing, _ := core.LoadMultiAppConfig()
@@ -410,36 +344,34 @@ func configInitRun(opts *ConfigInitOptions) error {
// New secret provided (either from "create" or "existing" with input) // New secret provided (either from "create" or "existing" with input)
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "%v", err)
} }
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err) return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
} }
} else if result.Mode == "existing" && result.AppID != "" { } 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
if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil { if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
return err // Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
} }
} else { } else {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty"). return output.ErrValidation("App ID and App Secret cannot be empty")
WithParam("--app-id")
} }
if result.Mode == "existing" { if result.Mode == "existing" {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID)) output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
} }
printLangPreferenceConfirmation(opts)
if result.AppSecret != "" {
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
}
return nil return nil
} }
// Non-terminal: cannot run interactive mode, guide user to --new // Non-terminal: cannot run interactive mode, guide user to --new
if !f.IOStreams.IsTerminal { if !f.IOStreams.IsTerminal {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.") return output.ErrValidation("config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
} }
// Mode 5: Legacy interactive (readline fallback) // Mode 5: Legacy interactive (readline fallback)
@@ -467,7 +399,7 @@ func configInitRun(opts *ConfigInitOptions) error {
} }
appIdInput, err := readLine(prompt) appIdInput, err := readLine(prompt)
if err != nil { if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err) return output.ErrValidation("%s", err)
} }
prompt = "App Secret" prompt = "App Secret"
@@ -476,7 +408,7 @@ func configInitRun(opts *ConfigInitOptions) error {
} }
appSecretInput, err := readLine(prompt) appSecretInput, err := readLine(prompt)
if err != nil { if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err) return output.ErrValidation("%s", err)
} }
prompt = "Brand (lark/feishu)" prompt = "Brand (lark/feishu)"
@@ -487,7 +419,7 @@ func configInitRun(opts *ConfigInitOptions) error {
} }
brandInput, err := readLine(prompt) brandInput, err := readLine(prompt)
if err != nil { if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err) return output.ErrValidation("%s", err)
} }
resolvedAppId := appIdInput resolvedAppId := appIdInput
@@ -509,23 +441,16 @@ func configInitRun(opts *ConfigInitOptions) error {
} }
if resolvedAppId == "" || resolvedSecret.IsZero() { if resolvedAppId == "" || resolvedSecret.IsZero() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty"). return output.ErrValidation("App ID and App Secret cannot be empty")
WithParam("--app-id")
} }
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain) storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "%v", err)
} }
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil { if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return wrapSaveConfigError(err) return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
} }
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
if appSecretInput != "" {
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
return err
}
}
return nil return nil
} }

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

@@ -6,17 +6,16 @@ package config
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/build"
qrcode "github.com/skip2/go-qrcode" qrcode "github.com/skip2/go-qrcode"
"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"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
) )
// configInitResult holds the result of the interactive config init flow. // configInitResult holds the result of the interactive config init flow.
@@ -126,16 +125,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
}, nil }, nil
} }
switch { if appID == "" || appSecret == "" {
case appID == "" && appSecret == "": return nil, output.ErrValidation("App ID and App Secret cannot be empty")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
WithParam("--app-id")
case appID == "":
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID cannot be empty").
WithParam("--app-id")
case appSecret == "":
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty").
WithParam("--app-secret")
} }
return &configInitResult{ return &configInitResult{
@@ -177,12 +168,10 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
} }
// Step 1: Request app registration (begin) // Step 1: Request app registration (begin)
// Use the shared proxy-plugin-aware transport so registration traffic is not httpClient := &http.Client{}
// a bypass of proxy plugin mode.
httpClient := transport.NewHTTPClient(0)
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut) authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil { if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err) return nil, output.ErrAuth("app registration failed: %v", err)
} }
// Step 2: Build and display verification URL + QR code // Step 2: Build and display verification URL + QR code
@@ -210,7 +199,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
} }
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil { if err != nil {
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err) return nil, output.ErrAuth("%v", err)
} }
// Step 4: Handle Lark brand special case // Step 4: Handle Lark brand special case
@@ -219,12 +208,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant) // fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil { if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err) return nil, output.ErrAuth("lark endpoint retry failed: %v", err)
} }
} }
if result.ClientID == "" || result.ClientSecret == "" { if result.ClientID == "" || result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret") return nil, output.ErrAuth("app registration succeeded but missing client_id or client_secret")
} }
// Determine final brand from response // Determine final brand from response

View File

@@ -4,14 +4,9 @@
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/output"
) )
type initMsg struct { type initMsg struct {
@@ -31,10 +26,6 @@ type initMsg struct {
DetectedLarkTenant string DetectedLarkTenant string
AppCreated string AppCreated string
ConfigSaved string ConfigSaved string
// LangPreferenceSet is printed to stderr after a successful init when the
// user explicitly passed --lang. Format: language code.
LangPreferenceSet string
} }
var initMsgZh = &initMsg{ var initMsgZh = &initMsg{
@@ -52,7 +43,6 @@ var initMsgZh = &initMsg{
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...", DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s", AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s", ConfigSaved: "应用配置成功! App ID: %s",
LangPreferenceSet: "语言偏好已设置:%s",
} }
var initMsgEn = &initMsg{ var initMsgEn = &initMsg{
@@ -70,27 +60,29 @@ var initMsgEn = &initMsg{
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...", DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s", AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s", ConfigSaved: "App configured! App ID: %s",
LangPreferenceSet: "Language preference set to: %s",
} }
// getInitMsg picks the zh/en TUI bundle; non-English falls back to zh. func getInitMsg(lang string) *initMsg {
func getInitMsg(lang i18n.Lang) *initMsg { if lang == "en" {
if lang.IsEnglish() {
return initMsgEn return initMsgEn
} }
return initMsgZh return initMsgZh
} }
// promptLangSelection shows the 中文/English picker and returns the chosen locale. // promptLangSelection shows an interactive language picker and returns the chosen lang code.
func promptLangSelection() (i18n.Lang, error) { // savedLang is used as the pre-selected default (from existing config).
lang := i18n.LangZhCN func promptLangSelection(savedLang string) (string, error) {
lang := savedLang
if lang != "en" {
lang = "zh"
}
form := huh.NewForm( form := huh.NewForm(
huh.NewGroup( huh.NewGroup(
huh.NewSelect[i18n.Lang](). huh.NewSelect[string]().
Title("Language / 语言"). Title("Language / 语言").
Options( Options(
huh.NewOption("中文", i18n.LangZhCN), huh.NewOption("中文", "zh"),
huh.NewOption("English", i18n.LangEnUS), huh.NewOption("English", "en"),
). ).
Value(&lang), Value(&lang),
), ),
@@ -101,12 +93,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

@@ -6,8 +6,6 @@ package config
import ( import (
"fmt" "fmt"
"testing" "testing"
"github.com/larksuite/cli/internal/i18n"
) )
func TestGetInitMsg_Zh(t *testing.T) { func TestGetInitMsg_Zh(t *testing.T) {
@@ -31,7 +29,7 @@ func TestGetInitMsg_En(t *testing.T) {
} }
func TestGetInitMsg_DefaultsToZh(t *testing.T) { func TestGetInitMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []i18n.Lang{"", "unknown", "xyz", "invalid"} { for _, lang := range []string{"", "fr", "ja", "unknown"} {
msg := getInitMsg(lang) msg := getInitMsg(lang)
if msg != initMsgZh { if msg != initMsgZh {
t.Errorf("getInitMsg(%q) should default to zh", lang) t.Errorf("getInitMsg(%q) should default to zh", lang)
@@ -64,7 +62,6 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
"DetectedLarkTenant": msg.DetectedLarkTenant, "DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated, "AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved, "ConfigSaved": msg.ConfigSaved,
"LangPreferenceSet": msg.LangPreferenceSet,
} }
for name, val := range fields { for name, val := range fields {
if val == "" { if val == "" {
@@ -74,7 +71,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
} }
func TestInitMsg_FormatStrings(t *testing.T) { func TestInitMsg_FormatStrings(t *testing.T) {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} { for _, lang := range []string{"zh", "en"} {
msg := getInitMsg(lang) msg := getInitMsg(lang)
// AppCreated and ConfigSaved should contain %s for App ID // AppCreated and ConfigSaved should contain %s for App ID
got := fmt.Sprintf(msg.AppCreated, "cli_test123") got := fmt.Sprintf(msg.AppCreated, "cli_test123")
@@ -87,37 +84,3 @@ func TestInitMsg_FormatStrings(t *testing.T) {
} }
} }
} }
func TestGetInitMsg_BilingualCollapse(t *testing.T) {
// The TUI is bilingual (zh + en). Only English-bucket languages return the
// English struct — by canonical locale ("en_us") or legacy short ("en").
// Everything else (zh, the other codes, invalid, "") returns Chinese.
tests := []struct {
lang i18n.Lang
shouldBeEn bool
}{
{i18n.LangZhCN, false},
{i18n.LangEnUS, true},
{"en", true}, // legacy short value
{i18n.LangJaJP, false},
{"fr_fr", false},
{"invalid", false},
{"", false},
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
msg := getInitMsg(tt.lang)
if msg == nil {
t.Fatal("getInitMsg returned nil")
}
want := initMsgZh
if tt.shouldBeEn {
want = initMsgEn
}
if msg != want {
t.Errorf("getInitMsg(%q) returned wrong struct", tt.lang)
}
})
}
}

View File

@@ -1,92 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// probeTimeout is the total wall-clock budget for the credential probe step
// (covering both TAT acquisition and the subsequent probe request).
const probeTimeout = 3 * time.Second
// runProbe runs a best-effort credential validation after config init has
// persisted the App ID and App Secret. It returns a non-nil error only for a
// deterministic credential-rejection signal; every other outcome returns nil
// so that valid configurations and transient/upstream noise never block the
// command.
//
// The function performs up to two HTTP calls in series, bounded by
// probeTimeout:
//
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
// returns a typed errs.* error (via the shared classifyTATResponseCode)
// only when the unified Token Endpoint deterministically rejected the
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
// typed error is propagated so the root dispatcher renders the canonical
// envelope and `config init` exits non-zero — identical to how every other
// token-resolving command reports the same bad credentials. Ambiguous
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
// timeouts) come back as raw untyped errors and are swallowed (return nil),
// so valid configurations are never disturbed by upstream noise.
// errs.IsTyped is the discriminator.
//
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
// that call (success, server error, timeout, parse failure) is always
// ignored — return nil regardless.
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
if factory == nil {
return nil
}
httpClient, err := factory.HttpClient()
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
if err != nil {
// A typed error from FetchTAT is a deterministic credential rejection
// (classifyTATResponseCode). Propagate it so config init exits with the
// same envelope the rest of the CLI uses for bad credentials. Untyped
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
// silent and let the command succeed.
if errs.IsTyped(err) {
return err
}
return nil
}
// TAT succeeded — fire the probe call. Any outcome is ignored.
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}

View File

@@ -1,287 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// fakeRT routes requests to per-path handlers and records what it saw.
type fakeRT struct {
tatHandler func(req *http.Request) (*http.Response, error)
probeHandler func(req *http.Request) (*http.Response, error)
tatCalls int
probeCalls int
probeReq *http.Request
probeBody string
}
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
f.tatCalls++
if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
}
return f.tatHandler(req)
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
f.probeCalls++
f.probeReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
f.probeBody = string(b)
}
if f.probeHandler == nil {
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
}
return f.probeHandler(req)
}
return nil, errors.New("unexpected URL: " + req.URL.String())
}
func jsonResp(code int, body string) *http.Response {
return &http.Response{
StatusCode: code,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}
// fakeFactory builds a test Factory whose HttpClient is overridden to use
// the caller-supplied RoundTripper.
//
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
// guidance). The HttpClient is then swapped to our stub so we can drive
// exact HTTP responses for the probe. Config-dir isolation is set up via
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
// touch lands in a temp dir rather than the developer's real config.
//
// The returned buffer is the Factory's stderr. runProbe never writes to
// stderr (it propagates a typed error or stays silent), so every test asserts
// this buffer stays empty as an invariant.
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
f.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
}
return f, errBuf
}
// assertConfigRejection asserts runProbe propagated a deterministic credential
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
// is the same typed error every other token-resolving command returns for the
// same bad credentials, and nothing is written to stderr (the root dispatcher
// renders the envelope). The numeric code is not asserted: the unified v3 Token
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper()
if err == nil {
t.Fatal("expected *errs.ConfigError, got nil")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
}
if cfgErr.Category != errs.CategoryConfig {
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
}
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
// written to stderr. Used for every ambiguous (non-credential) outcome.
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper()
if err != nil {
t.Errorf("expected nil (silent), got error: %v", err)
}
if errBuf.Len() != 0 {
t.Errorf("expected no stderr output, got: %q", errBuf.String())
}
}
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
// propagated. The probe endpoint must not be called when TAT fails.
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails")
}
assertConfigRejection(t, err, errBuf)
}
// unauthorized_client is treated as the same credential rejection, propagated.
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
// it rather than swallowing — but is not a credential (ConfigError) rejection.
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if err == nil || !errs.IsTyped(err) {
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
}
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
// rejection) → silent, exit 0.
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
for _, code := range []int{401, 403, 500} {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(code, `nope`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
}
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return nil, errors.New("network down")
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
rt := &fakeRT{
probeHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(500, `server error`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 1 {
t.Errorf("probe should be called once, got %d", rt.probeCalls)
}
assertSilent(t, err, errBuf)
}
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
rt := &fakeRT{}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.tatCalls != 1 || rt.probeCalls != 1 {
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
}
assertSilent(t, err, errBuf)
}
func TestRunProbe_ProbeRequestShape(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
t.Fatal("probe request not captured")
}
if rt.probeReq.Method != http.MethodPost {
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
}
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
t.Errorf("probe URL = %s", got)
}
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
t.Errorf("Authorization = %q, want Bearer t-ok", got)
}
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
t.Errorf("probe body missing from field: %s", rt.probeBody)
}
}
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
t.Fatal("probe request not captured")
}
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
}
}
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
f.HttpClient = func() (*http.Client, error) {
return nil, errors.New("client init failed")
}
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TimeoutHonored(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
},
}
f, errBuf := fakeFactory(t, rt)
start := time.Now()
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
elapsed := time.Since(start)
if elapsed > 4*time.Second {
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
}
// A timeout is an ambiguous failure (context deadline → untyped), so it
// must stay silent and not block.
assertSilent(t, err, errBuf)
}

View File

@@ -1,121 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"fmt"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2:
// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials,
// not for missing user input.
func TestUpdateExistingProfileWithoutSecret_NilConfig_EmitsValidationError(t *testing.T) {
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", core.BrandFeishu, "en")
assertValidationParam(t, err, "--app-secret")
}
func TestUpdateExistingProfileWithoutSecret_UnknownProfile_EmitsValidationError(t *testing.T) {
existing := &core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
}},
}
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", core.BrandFeishu, "en")
assertValidationParam(t, err, "--app-secret")
}
func TestUpdateExistingProfileWithoutSecret_NoCurrentApp_EmitsValidationError(t *testing.T) {
existing := &core.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
}},
}
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", core.BrandFeishu, "en")
assertValidationParam(t, err, "--app-secret")
}
func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t *testing.T) {
existing := &core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
}},
}
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", core.BrandFeishu, "en")
assertValidationParam(t, err, "--app-secret")
}
// wrapUpdateExistingProfileErr is the caller-side classifier for the error
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error
// exit semantics: a typed ValidationError must keep ExitValidation rather than
// being downgraded to InternalError.
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
if got := wrapUpdateExistingProfileErr(nil); got != nil {
t.Fatalf("expected nil, got %v", got)
}
}
func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T) {
in := errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile").
WithParam("--app-secret")
got := wrapUpdateExistingProfileErr(in)
assertValidationParam(t, got, "--app-secret")
// Exit code must remain ExitValidation (2), not ExitInternal (5).
if code := output.ExitCodeOf(got); code != output.ExitValidation {
t.Errorf("ExitCodeOf = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// Must NOT be wrapped as *InternalError.
var intErr *errs.InternalError
if errors.As(got, &intErr) {
t.Errorf("typed ValidationError was downgraded to *InternalError: %v", got)
}
}
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
in := fmt.Errorf("disk full")
got := wrapUpdateExistingProfileErr(in)
var intErr *errs.InternalError
if !errors.As(got, &intErr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", got, got)
}
if intErr.Subtype != errs.SubtypeSDKError {
t.Errorf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError)
}
}
// assertValidationParam asserts err is *ValidationError with the given Param.
func assertValidationParam(t *testing.T, err error, wantParam string) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if valErr.Param != wantParam {
t.Errorf("Param = %q, want %q", valErr.Param, wantParam)
}
}

View File

@@ -1,72 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build darwin
package config
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
// NewCmdConfigKeychainDowngrade creates the macOS-only subcommand that pins
// the master key to the local file fallback (master.key.file) so subsequent
// operations bypass the OS Keychain. Useful inside sandboxes like Codex
// where the system Keychain is unreachable.
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "keychain-downgrade",
Short: "Downgrade keychain storage to a local file (macOS only)",
Long: `Materialize the master key from the macOS system Keychain into a local file
under ~/Library/Application Support/lark-cli/master.key.file, then pin all
subsequent reads to that file.
Intended workflow: run this once from an interactive Terminal session on
macOS (where the system Keychain is reachable). After it finishes,
sandboxed / automation / CI runs of lark-cli on the same machine will read
the master key from the local file and no longer need the OS Keychain.
This is the supported fix for environments like the Codex sandbox where the
system Keychain is blocked. Running keychain-downgrade from inside such a
sandbox will itself fail with "keychain access blocked" — that is expected;
run it from an interactive macOS session instead.
The OS Keychain entry is preserved as a cold backup; nothing is deleted there.
The command is idempotent: re-running it on an already-downgraded install
reports "already downgraded" and exits 0.`,
RunE: func(cmd *cobra.Command, args []string) error {
return configKeychainDowngradeRun(f)
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}
func configKeychainDowngradeRun(f *cmdutil.Factory) error {
service := keychain.LarkCliService
keyPath := keychain.MasterKeyFilePath(service)
result, err := keychain.DowngradeMasterKeyToFile(service)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError,
"keychain downgrade failed: %v", err).
WithHint("This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.").
WithCause(err)
}
switch result {
case keychain.DowngradeAlreadyDone:
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("keychain already downgraded; subsequent operations read from %s", keyPath))
case keychain.DowngradeUsedKeychainKey:
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("downgraded: copied master key from system Keychain to %s. Subsequent operations will read from file, bypassing the OS Keychain (useful inside sandboxes like Codex).", keyPath))
case keychain.DowngradeCreatedNewKey:
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("system Keychain was empty; generated a new master key and wrote it to %s. The OS Keychain was not modified.", keyPath))
}
return nil
}

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !darwin
package config
import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// NewCmdConfigKeychainDowngrade is registered on all platforms so that
// `lark-cli config --help` reads the same everywhere. On non-macOS it
// refuses with a clear message.
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
_ = f
cmd := &cobra.Command{
Use: "keychain-downgrade",
Short: "Downgrade keychain storage to a local file (macOS only)",
Long: `Downgrade keychain storage to a local file. This subcommand is only supported on macOS; on this platform the keychain layer already uses local files.`,
RunE: func(cmd *cobra.Command, args []string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "keychain-downgrade is only supported on macOS")
},
}
return cmd
}

View File

@@ -82,8 +82,8 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
"version": p.Version, "version": p.Version,
"capabilities": p.Capabilities, "capabilities": p.Capabilities,
} }
if len(p.Rules) > 0 { if p.Rule != nil {
entry["rules"] = p.Rules entry["rule"] = p.Rule
} }
entry["hooks"] = map[string]any{ entry["hooks"] = map[string]any{
"observers": p.Observers, "observers": p.Observers,

View File

@@ -59,20 +59,16 @@ func runConfigPolicyShow(f *cmdutil.Factory) error {
"source_name": sourceName, "source_name": sourceName,
"denied_paths": active.DeniedPaths, "denied_paths": active.DeniedPaths,
} }
if len(active.Rules) > 0 { if active.Rule != nil {
rules := make([]map[string]any, 0, len(active.Rules)) out["rule"] = map[string]any{
for _, r := range active.Rules { "name": active.Rule.Name,
rules = append(rules, map[string]any{ "description": active.Rule.Description,
"name": r.Name, "allow": active.Rule.Allow,
"description": r.Description, "deny": active.Rule.Deny,
"allow": r.Allow, "max_risk": active.Rule.MaxRisk,
"deny": r.Deny, "identities": active.Rule.Identities,
"max_risk": r.MaxRisk, "allow_unannotated": active.Rule.AllowUnannotated,
"identities": r.Identities,
"allow_unannotated": r.AllowUnannotated,
})
} }
out["rules"] = rules
} }
output.PrintJson(f.IOStreams.Out, out) output.PrintJson(f.IOStreams.Out, out)
return nil return nil

View File

@@ -57,7 +57,7 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
MaxRisk: "read", MaxRisk: "read",
} }
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{ cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rules: []*platform.Rule{rule}, Rule: rule,
Source: cmdpolicy.ResolveSource{ Source: cmdpolicy.ResolveSource{
Kind: cmdpolicy.SourcePlugin, Kind: cmdpolicy.SourcePlugin,
Name: "secaudit", Name: "secaudit",
@@ -83,16 +83,12 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
if got["denied_paths"] != float64(42) { if got["denied_paths"] != float64(42) {
t.Errorf("denied_paths = %v, want 42", got["denied_paths"]) t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
} }
rulesAny, ok := got["rules"].([]any) ruleMap, ok := got["rule"].(map[string]any)
if !ok || len(rulesAny) != 1 {
t.Fatalf("rules field missing or wrong shape: %v", got["rules"])
}
ruleMap, ok := rulesAny[0].(map[string]any)
if !ok { if !ok {
t.Fatalf("rules[0] wrong type") t.Fatalf("rule field missing or wrong type")
} }
if ruleMap["name"] != "secaudit" { if ruleMap["name"] != "secaudit" {
t.Errorf("rules[0].name = %v", ruleMap["name"]) t.Errorf("rule.name = %v", ruleMap["name"])
} }
} }
@@ -105,7 +101,7 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
t.Cleanup(cmdpolicy.ResetActiveForTesting) t.Cleanup(cmdpolicy.ResetActiveForTesting)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{ cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rules: []*platform.Rule{{Name: "my-yaml-rule"}}, Rule: &platform.Rule{Name: "my-yaml-rule"},
Source: cmdpolicy.ResolveSource{ Source: cmdpolicy.ResolveSource{
Kind: cmdpolicy.SourceYAML, Kind: cmdpolicy.SourceYAML,
Name: "/Users/alice/.lark-cli/policy.yml", Name: "/Users/alice/.lark-cli/policy.yml",

View File

@@ -6,7 +6,6 @@ package config
import ( import (
"fmt" "fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth" "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"
@@ -43,14 +42,14 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
config, err := core.LoadMultiAppConfig() config, err := core.LoadMultiAppConfig()
if err != nil || config == nil || len(config.Apps) == 0 { if err != nil || config == nil || len(config.Apps) == 0 {
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet") return output.ErrValidation("not configured yet")
} }
// Save empty config first. If this fails, keep secrets and tokens intact so the // Save empty config first. If this fails, keep secrets and tokens intact so the
// existing config can still be retried instead of ending up half-removed. // existing config can still be retried instead of ending up half-removed.
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}} empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
if err := core.SaveMultiAppConfig(empty); err != nil { if err := core.SaveMultiAppConfig(empty); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
} }
// Clean up keychain entries for all apps after config is cleared. // Clean up keychain entries for all apps after config is cleared.

View File

@@ -9,7 +9,6 @@ import (
"os" "os"
"strings" "strings"
"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"
@@ -48,14 +47,14 @@ func configShowRun(opts *ConfigShowOptions) error {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return core.NotConfiguredError() return core.NotConfiguredError()
} }
return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err) return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
} }
if config == nil || len(config.Apps) == 0 { if config == nil || len(config.Apps) == 0 {
return core.NotConfiguredError() return core.NotConfiguredError()
} }
app := config.CurrentAppConfig(f.Invocation.Profile) app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil { if app == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list") return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli profile list")
} }
users := "(no logged-in users)" users := "(no logged-in users)"
if len(app.Users) > 0 { if len(app.Users) > 0 {

View File

@@ -7,9 +7,9 @@ import (
"context" "context"
"fmt" "fmt"
"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/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -73,14 +73,14 @@ explicit user confirmation — never run on your own initiative.`,
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error { func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
if global { if global {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset") return output.ErrValidation("--reset cannot be used with --global")
} }
if len(args) > 0 { if len(args) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset") return output.ErrValidation("--reset cannot be used with a value argument")
} }
app.StrictMode = nil app.StrictMode = nil
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)
} }
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)") fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
return nil return nil
@@ -104,7 +104,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
switch mode { switch mode {
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff: case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
default: default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value) return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
} }
// Capture the old mode at the SAME scope being changed, so we can warn // Capture the old mode at the SAME scope being changed, so we can warn
@@ -144,7 +144,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
} }
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)
} }
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) { if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {

View File

@@ -14,13 +14,11 @@ 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"
"github.com/larksuite/cli/internal/identitydiag" "github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update" "github.com/larksuite/cli/internal/update"
) )
@@ -95,7 +93,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 +107,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
} }
@@ -154,9 +152,7 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
} }
} }
// Use the shared proxy-plugin-aware transport so connectivity checks reflect httpClient := &http.Client{}
// the real egress path (and are blocked when proxy plugin fails closed).
httpClient := transport.NewHTTPClient(0)
mcpURL := ep.MCP + "/mcp" mcpURL := ep.MCP + "/mcp"
type probeResult struct { type probeResult struct {

View File

@@ -11,10 +11,10 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
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"
@@ -23,8 +23,12 @@ import (
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a // applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
// "current command requires scope(s): X, Y" hint when the underlying error is // "current command requires scope(s): X, Y" hint when the underlying error is
// a need_user_authorization signal AND the current command declares scopes // a need_user_authorization signal AND the current command declares scopes
// locally (via shortcut registration or service-method metadata). Existing // locally (via shortcut registration or service-method metadata).
// Hint text is preserved; scopes are appended on a new line. //
// Stage-1: this typed path is dormant — no production code returns a typed
// *errs.AuthenticationError. Kept so per-domain stage-2 migrations can plug
// in without re-architecting. The active stage-1 path is
// enrichMissingScopeError below, which operates on legacy *output.ExitError.
func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) { func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
if err == nil || f == nil { if err == nil || f == nil {
return return
@@ -48,6 +52,34 @@ 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. Matches pre-PR behaviour byte-for-byte; lives on the legacy
// envelope path until per-domain stage-2 typed migration.
//
// Deprecated: stage-1 enrichment for the legacy *output.ExitError surface.
// Stage-2 typed migration will lift this into AuthenticationError.Hint on
// the typed envelope via applyNeedAuthorizationHint and remove this helper.
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.
@@ -92,37 +124,78 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
} }
// resolveDeclaredServiceMethodScopes returns the scopes declared by a // resolveDeclaredServiceMethodScopes returns the scopes declared by a
// service/resource/method command. It reconstructs the catalog path from the // service/resource/method command from the embedded from_meta registry.
// command ancestry and resolves it through the same navigation Module the
// command tree is built from (apicatalog), so it stays correct for nested
// resources instead of hard-coding a root->service->resource->method depth.
// Non-method commands (services, resources, shortcuts) resolve to a non-method
// target and yield no scopes.
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string { func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") { // Service-method scope lookup only applies to commands mounted as
// root -> service -> resource -> method. Non-resource/method commands
// intentionally return no scopes here so auth-hint enrichment does not
// change runtime semantics for other command shapes.
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
return nil return nil
} }
path := commandCatalogPath(cmd) if strings.HasPrefix(cmd.Name(), "+") {
if len(path) == 0 {
return nil return nil
} }
target, err := registry.RuntimeCatalog().Resolve(path)
if err != nil || target.Kind != apicatalog.TargetMethod { service := cmd.Parent().Parent().Name()
resource := cmd.Parent().Name()
method := cmd.Name()
spec := registry.LoadFromMeta(service)
if spec == nil {
return nil return nil
} }
return registry.DeclaredScopesForMethod(target.Method.Method, identity) resources, _ := spec["resources"].(map[string]interface{})
resMap, _ := resources[resource].(map[string]interface{})
if resMap == nil {
return nil
}
methods, _ := resMap["methods"].(map[string]interface{})
methodMap, _ := methods[method].(map[string]interface{})
if methodMap == nil {
return nil
}
return declaredScopesForMethod(methodMap, identity)
} }
// commandCatalogPath reconstructs the catalog path [service, resource..., method] // declaredScopesForMethod returns all requiredScopes when present; otherwise it
// from a command's ancestry, excluding the root command. It is the inverse of // resolves the single recommended scope from the method's scopes list.
// the service command tree's construction, so any depth (flat or nested) func declaredScopesForMethod(method map[string]interface{}, identity string) []string {
// round-trips through apicatalog.Resolve. if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 {
func commandCatalogPath(cmd *cobra.Command) []string { return interfaceStrings(requiredRaw)
var path []string
for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() {
path = append([]string{c.Name()}, path...)
} }
return path
rawScopes, _ := method["scopes"].([]interface{})
if len(rawScopes) == 0 {
return nil
}
recommended := registry.SelectRecommendedScope(rawScopes, identity)
if recommended == "" {
for _, raw := range rawScopes {
if scope, ok := raw.(string); ok && scope != "" {
recommended = scope
break
}
}
}
if recommended == "" {
return nil
}
return []string{recommended}
}
// interfaceStrings converts a []interface{} containing strings into a compact
// []string, skipping empty or non-string values.
func interfaceStrings(values []interface{}) []string {
scopes := make([]string, 0, len(values))
for _, value := range values {
scope, ok := value.(string)
if !ok || scope == "" {
continue
}
scopes = append(scopes, scope)
}
return scopes
} }
// shortcutSupportsIdentity reports whether a shortcut supports the requested // shortcutSupportsIdentity reports whether a shortcut supports the requested

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

@@ -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/event" "github.com/larksuite/cli/internal/event"
@@ -39,8 +38,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
logger, err := bus.SetupBusLogger(eventsDir) logger, err := bus.SetupBusLogger(eventsDir)
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, return err
"set up bus logger: %s", err).WithCause(err)
} }
tr := transport.New() tr := transport.New()
@@ -60,14 +58,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
} }
}() }()
if err := b.Run(ctx); err != nil { return b.Run(ctx)
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus daemon exited: %s", err).WithCause(err)
}
return nil
}, },
} }

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// The hidden `event _bus` daemon command must exit with a typed file_io error
// when its log directory cannot be created (the error is only visible in the
// forked process's captured stderr / bus.log).
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Block the events/ root with a regular file so MkdirAll fails.
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
cmd := NewCmdBus(f)
cmd.SetArgs([]string{})
err := cmd.Execute()
if err == nil {
t.Fatal("expected logger setup error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryInternal, errs.SubtypeFileIO)
}
}

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

@@ -16,7 +16,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta" "github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
@@ -65,8 +64,8 @@ Use 'event schema <EventKey>' for parameter details.`,
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output") cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr") cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)") cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.") cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.") cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)") cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { _ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
@@ -102,10 +101,11 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
if o.jqExpr != "" { if o.jqExpr != "" {
if err := output.ValidateJqExpression(o.jqExpr); err != nil { if err := output.ValidateJqExpression(o.jqExpr); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err). return output.ErrWithHint(
WithParam("--jq"). output.ExitValidation, "validation",
WithCause(err). err.Error(),
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey) fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
)
} }
} }
@@ -146,28 +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
@@ -198,9 +184,8 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
errOut = io.Discard errOut = io.Discard
} }
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers. // Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
// Bounded runs already have --max-events/--timeout as their lifecycle control. if !f.IOStreams.IsTerminal {
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
watchStdinEOF(os.Stdin, cancel, errOut) watchStdinEOF(os.Stdin, cancel, errOut)
} }
@@ -243,9 +228,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).
@@ -278,87 +260,63 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
if len(missing) == 0 { if len(missing) == 0 {
return nil return nil
} }
return errs.NewPermissionError(errs.SubtypeMissingScope, return output.ErrWithHint(
"missing required scopes for EventKey %s (as %s): %s", output.ExitAuth, "auth",
pf.eventKey, pf.identity, strings.Join(missing, ", ")). fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
WithIdentity(string(pf.identity)). pf.eventKey, pf.identity, strings.Join(missing, ", ")),
WithMissingScopes(missing...). scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing)) )
} }
// 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
} }
return output.ErrWithHint(
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing)) output.ExitValidation, "validation",
return errs.NewValidationError(errs.SubtypeFailedPrecondition, fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
"EventKey %s requires %s not subscribed in console: %s", pf.keyDef.Key, strings.Join(missing, ", ")),
pf.keyDef.Key, noun, strings.Join(missing, ", ")). fmt.Sprintf("subscribe these events and publish a new app version at: %s",
WithHint("subscribe these %s by scanning: %s", noun, url) 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).
func sanitizeOutputDir(dir string) (string, error) { func sanitizeOutputDir(dir string) (string, error) {
if strings.HasPrefix(dir, "~") { if strings.HasPrefix(dir, "~") {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
"%s; use a relative path like ./output instead", errOutputDirTilde).
WithParam("--output-dir").
WithCause(errOutputDirTilde)
} }
safe, err := validate.SafeOutputPath(dir) safe, err := validate.SafeOutputPath(dir)
if err != nil { if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
"%s %q: %s", errOutputDirUnsafe, dir, err).
WithParam("--output-dir").
WithCause(errOutputDirUnsafe)
} }
return safe, nil return safe, nil
} }
@@ -370,25 +328,22 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
} }
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID)) result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
if err != nil { if err != nil {
if _, ok := errs.ProblemOf(err); ok { return "", output.ErrAuth("resolve tenant access token: %s", err)
return "", err
}
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"resolve tenant access token: %s", err).WithCause(err)
} }
if result == nil || result.Token == "" { if result == nil || result.Token == "" {
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing, return "", output.ErrWithHint(
"no tenant access token available for app %s", appID). output.ExitAuth, "auth",
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.") fmt.Sprintf("no tenant access token available for app %s", appID),
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
)
} }
return result.Token, nil return result.Token, nil
} }
// 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) {
@@ -396,10 +351,7 @@ func parseParams(raw []string) (map[string]string, error) {
for _, kv := range raw { for _, kv := range raw {
k, v, ok := strings.Cut(kv, "=") k, v, ok := strings.Cut(kv, "=")
if !ok || k == "" { if !ok || k == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
"%s %q: expected key=value", errInvalidParamFormat, kv).
WithParam("--param").
WithCause(errInvalidParamFormat)
} }
m[k] = v m[k] = v
} }
@@ -418,8 +370,3 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
cancel() cancel()
}() }()
} }
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
return !isTerminal && maxEvents <= 0 && timeout <= 0
}

View File

@@ -61,70 +61,3 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
t.Fatal("watchStdinEOF did not cancel within 1s of EOF") t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
} }
} }
func TestShouldWatchStdinEOF(t *testing.T) {
tests := []struct {
name string
isTerminal bool
maxEvents int
timeout time.Duration
want bool
}{
{
name: "terminal",
isTerminal: true,
want: false,
},
{
name: "non terminal unbounded",
want: true,
},
{
name: "non terminal negative max events is unbounded",
maxEvents: -1,
want: true,
},
{
name: "non terminal negative timeout is unbounded",
timeout: -1 * time.Second,
want: true,
},
{
name: "non terminal max events bounded",
maxEvents: 1,
want: false,
},
{
name: "non terminal timeout bounded",
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal both bounds positive",
maxEvents: 1,
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal bounded max events with negative timeout",
maxEvents: 1,
timeout: -1 * time.Second,
want: false,
},
{
name: "non terminal bounded timeout with negative max events",
maxEvents: -1,
timeout: 10 * time.Minute,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
if got != tt.want {
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -4,14 +4,9 @@
package event package event
import ( import (
"context"
"errors" "errors"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
) )
func TestParseParams(t *testing.T) { func TestParseParams(t *testing.T) {
@@ -78,7 +73,6 @@ func TestParseParams(t *testing.T) {
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) { if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho) t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
} }
assertInvalidArgumentParam(t, err, "--param")
return return
} }
if err != nil { if err != nil {
@@ -96,77 +90,6 @@ func TestParseParams(t *testing.T) {
} }
} }
// emptyTokenResolver resolves to a result that carries no token.
type emptyTokenResolver struct{}
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{}, nil
}
// failingTokenResolver fails outright with an untyped error.
type failingTokenResolver struct{}
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return nil, errors.New("backend unavailable")
}
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
}
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
var malformed *credential.MalformedTokenResultError
if !errors.As(err, &malformed) {
t.Error("empty-token failure should preserve the credential-layer cause")
}
}
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
if errors.Unwrap(err) == nil {
t.Error("resolver failure should preserve its cause")
}
}
// assertInvalidArgumentParam verifies err is a typed validation error with
// subtype invalid_argument naming the given flag in its param field.
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != param {
t.Errorf("param = %q, want %q", ve.Param, param)
}
}
func TestSanitizeOutputDir(t *testing.T) { func TestSanitizeOutputDir(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -207,7 +130,6 @@ func TestSanitizeOutputDir(t *testing.T) {
if !errors.Is(err, tc.wantSentry) { if !errors.Is(err, tc.wantSentry) {
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error()) t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
} }
assertInvalidArgumentParam(t, err, "--output-dir")
return return
} }
if err != nil { if err != nil {

View File

@@ -143,79 +143,6 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
} }
} }
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 2,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB column header: %s", out)
}
if !strings.Contains(out, "alice") {
t.Errorf("missing alice suffix in SUB column: %s", out)
}
if !strings.Contains(out, "bob") {
t.Errorf("missing bob suffix in SUB column: %s", out)
}
}
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
}
}
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
}
}
func TestWriteStatusJSON_OrphanHint(t *testing.T) { func TestWriteStatusJSON_OrphanHint(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
if err := writeStatusJSON(&buf, []appStatus{ if err := writeStatusJSON(&buf, []appStatus{
@@ -270,15 +197,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

@@ -8,10 +8,10 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta" "github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event" eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
) )
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx { func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
@@ -89,17 +89,19 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") { if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
t.Errorf("error should name the missing event type, got: %v", err) t.Errorf("error should name the missing event type, got: %v", err)
} }
p, ok := errs.ProblemOf(err) var exit *output.ExitError
if !ok { if !errors.As(err, &exit) {
t.Fatalf("expected typed errs error, got %T: %v", err, err) t.Fatalf("expected output.ExitError, got %T: %v", err, err)
} }
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition { if exit.Code != output.ExitValidation {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
} }
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons=" if exit.Detail == nil {
if !strings.Contains(p.Hint, wantURL) { t.Fatal("expected Detail with hint")
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint) }
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
if !strings.Contains(exit.Detail.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
} }
} }
@@ -143,22 +145,21 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
if !strings.Contains(err.Error(), "im:message.group_at_msg") { if !strings.Contains(err.Error(), "im:message.group_at_msg") {
t.Errorf("error should name missing scope, got: %v", err) t.Errorf("error should name missing scope, got: %v", err)
} }
var permErr *errs.PermissionError var exit *output.ExitError
if !errors.As(err, &permErr) { if !errors.As(err, &exit) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err) t.Fatalf("expected output.ExitError, got %T: %v", err, err)
} }
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope { if exit.Code != output.ExitAuth {
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype, t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
errs.CategoryAuthorization, errs.SubtypeMissingScope)
} }
wantMissing := []string{"im:message.group_at_msg"} if exit.Detail == nil {
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] { t.Fatal("expected Detail with hint, got nil Detail")
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
} }
hint := permErr.Hint hint := exit.Detail.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

@@ -6,8 +6,8 @@ package event
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
) )
@@ -26,11 +26,7 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
As: r.accessIdentity, As: r.accessIdentity,
}) })
if err != nil { if err != nil {
if _, ok := errs.ProblemOf(err); ok { return nil, err
return nil, err
}
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
"api %s %s: %s", method, path, err).WithCause(err)
} }
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing. // Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
ct := resp.Header.Get("Content-Type") ct := resp.Header.Get("Content-Type")
@@ -40,20 +36,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
if len(body) > maxBodyEcho { if len(body) > maxBodyEcho {
body = body[:maxBodyEcho] + "…(truncated)" body = body[:maxBodyEcho] + "…(truncated)"
} }
if resp.StatusCode >= 500 { return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
} }
result, err := client.ParseJSONResponse(resp) result, err := client.ParseJSONResponse(resp)
if err != nil { if err != nil {
if _, ok := errs.ProblemOf(err); ok { return nil, err
return nil, err
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s: %s", method, path, err).WithCause(err)
} }
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil { if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
return json.RawMessage(resp.RawBody), apiErr return json.RawMessage(resp.RawBody), apiErr

View File

@@ -1,147 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// staticTokenResolver always returns a fixed token without any HTTP calls.
type staticTokenResolver struct{}
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token"}, nil
}
// stubRoundTripper intercepts every outgoing request with a canned response.
type stubRoundTripper struct {
respond func(*http.Request) (*http.Response, error)
}
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
sdk := lark.NewClient("test-app", "test-secret",
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(&http.Client{Transport: rt}),
)
return &consumeRuntime{
client: &client.APIClient{
SDK: sdk,
ErrOut: io.Discard,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
},
accessIdentity: core.AsBot,
}
}
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
return func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(body)),
Request: r,
}, nil
}
}
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
if !strings.Contains(err.Error(), "returned 404") {
t.Errorf("error should echo the HTTP status, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
long := strings.Repeat("x", 300)
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
p, _ := errs.ProblemOf(err)
if !p.Retryable {
t.Fatal("5xx non-JSON response should be marked retryable")
}
if !strings.Contains(err.Error(), "…(truncated)") {
t.Errorf("long body should be truncated in the message, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
}
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
return nil, errors.New("connection refused")
}})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
}
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":99991663,"msg":"app not found"}`)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
}
}
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":0,"data":{"ok":true}}`)})
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(string(raw), `"code":0`) {
t.Errorf("raw body should pass through, got: %s", raw)
}
}

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"
eventlib "github.com/larksuite/cli/internal/event" eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas" "github.com/larksuite/cli/internal/event/schemas"
@@ -40,14 +39,12 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
if len(def.Schema.FieldOverrides) > 0 { if len(def.Schema.FieldOverrides) > 0 {
var parsed map[string]interface{} var parsed map[string]interface{}
if err := json.Unmarshal(base, &parsed); err != nil { if err := json.Unmarshal(base, &parsed); err != nil {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown, return nil, nil, err
"parse base schema for field overrides: %s", err).WithCause(err)
} }
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides) orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
out, err := json.Marshal(parsed) out, err := json.Marshal(parsed)
if err != nil { if err != nil {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown, return nil, nil, err
"serialize schema with field overrides: %s", err).WithCause(err)
} }
return out, orphans, nil return out, orphans, nil
} }
@@ -76,7 +73,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
copy(buf, s.Raw) copy(buf, s.Raw)
return buf, nil return buf, nil
} }
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw") return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
} }
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command { func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
@@ -134,16 +131,12 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
if len(def.Params) > 0 { if len(def.Params) > 0 {
fmt.Fprintf(out, "\nParameters:\n") fmt.Fprintf(out, "\nParameters:\n")
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n") fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
for _, p := range def.Params { for _, p := range def.Params {
required := "no" required := "no"
if p.Required { if p.Required {
required = "yes" required = "yes"
} }
subKey := "no"
if p.SubscriptionKey {
subKey = "yes"
}
defaultVal := p.Default defaultVal := p.Default
if defaultVal == "" { if defaultVal == "" {
defaultVal = "-" defaultVal = "-"
@@ -152,7 +145,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
if desc == "" { if desc == "" {
desc = "-" desc = "-"
} }
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc) fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
} }
w.Flush() w.Flush()
@@ -172,7 +165,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
resolved, _, err := resolveSchemaJSON(def) resolved, _, err := resolveSchemaJSON(def)
if err != nil { if err != nil {
return err return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
} }
if resolved != nil { if resolved != nil {
fmt.Fprintf(out, "\nOutput Schema:\n") fmt.Fprintf(out, "\nOutput Schema:\n")

View File

@@ -10,7 +10,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"
eventlib "github.com/larksuite/cli/internal/event" eventlib "github.com/larksuite/cli/internal/event"
@@ -96,79 +95,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
} }
} }
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
{Name: "folders", Description: "filter only"},
},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, false); err != nil {
t.Fatalf("runSchema: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "SUB-KEY") {
t.Errorf("missing SUB-KEY column header in:\n%s", out)
}
// Find the mailbox row and verify "yes" is present
var mailboxRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
mailboxRow = ln
break
}
}
if !strings.Contains(mailboxRow, "yes") {
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
}
// Find the folders row and verify "no" is present
var foldersRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
foldersRow = ln
break
}
}
if !strings.Contains(foldersRow, "no") {
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
}
}
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
const syntheticKey = "test.evt_json"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
if !strings.Contains(stdout.String(), `"subscription_key"`) {
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `true`) {
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
}
}
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) { func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
const syntheticKey = "t.custom.overlay" const syntheticKey = "t.custom.overlay"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) }) t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
@@ -203,38 +129,3 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
t.Errorf("overlay format = %v, want open_id", got) t.Errorf("overlay format = %v, want open_id", got)
} }
} }
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
_, err := renderSpec(&eventlib.SchemaSpec{})
if err == nil {
t.Fatal("expected error for spec with neither Type nor Raw")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "synthetic.invalid.base",
Schema: eventlib.SchemaDef{
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
},
}
_, _, err := resolveSchemaJSON(def)
if err == nil {
t.Fatal("expected error for unparsable base schema")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}

View File

@@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"io" "io"
"sort" "sort"
"strings"
"sync" "sync"
"time" "time"
@@ -243,17 +242,12 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
s.PID, (time.Duration(s.UptimeSec) * time.Second).String()) s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
fmt.Fprintf(out, " Active consumers: %d\n", s.Active) fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
if len(s.Consumers) > 0 { if len(s.Consumers) > 0 {
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"} headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
rows := make([][]string, 0, len(s.Consumers)) rows := make([][]string, 0, len(s.Consumers))
for _, c := range s.Consumers { for _, c := range s.Consumers {
subDisplay := "-"
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
}
rows = append(rows, []string{ rows = append(rows, []string{
fmt.Sprintf("pid=%d", c.PID), fmt.Sprintf("pid=%d", c.PID),
c.EventKey, c.EventKey,
subDisplay,
fmt.Sprintf("%d", c.Received), fmt.Sprintf("%d", c.Received),
fmt.Sprintf("%d", c.Dropped), fmt.Sprintf("%d", c.Dropped),
}) })

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

@@ -8,9 +8,8 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/larksuite/cli/errs"
eventlib "github.com/larksuite/cli/internal/event" eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/suggest" "github.com/larksuite/cli/internal/output"
) )
const maxSuggestions = 3 const maxSuggestions = 3
@@ -29,7 +28,7 @@ func suggestEventKeys(input string) []string {
hits = append(hits, match{def.Key, 0}) hits = append(hits, match{def.Key, 0})
continue continue
} }
if d := suggest.Levenshtein(input, def.Key); d <= threshold { if d := levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d}) hits = append(hits, match{def.Key, d})
} }
} }
@@ -64,6 +63,40 @@ func unknownEventKeyErr(key string) error {
if guesses := suggestEventKeys(key); len(guesses) > 0 { if guesses := suggestEventKeys(key); len(guesses) > 0 {
msg += " — did you mean " + formatSuggestions(guesses) + "?" msg += " — did you mean " + formatSuggestions(guesses) + "?"
} }
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg). return output.ErrWithHint(
WithHint("Run 'lark-cli event list' to see available keys.") output.ExitValidation, "validation",
msg,
"Run 'lark-cli event list' to see available keys.",
)
}
// levenshtein computes classic edit distance (two-row DP).
func levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
} }

View File

@@ -10,6 +10,27 @@ import (
_ "github.com/larksuite/cli/events" _ "github.com/larksuite/cli/events"
) )
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "abc", 3},
{"kitten", "kitten", 0},
{"kitten", "sitten", 1},
{"kitten", "sitting", 3},
{"飞书", "飞书", 0},
{"飞书", "飞s", 1},
}
for _, tc := range cases {
if got := levenshtein(tc.a, tc.b); got != tc.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestSuggestEventKeys(t *testing.T) { func TestSuggestEventKeys(t *testing.T) {
cases := []struct { cases := []struct {
name string name string

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
func TestUnknownFlagName(t *testing.T) {
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --query", "query", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagName(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
}
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().String("find", "", "")
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// The offending flag is carried structurally on Params (replaces the
// legacy detail map) and named in the message.
if len(verr.Params) != 1 || verr.Params[0].Name != "--rang" {
t.Errorf("Params = %v, want one entry named --rang", verr.Params)
}
if len(verr.Params) == 1 && verr.Params[0].Reason == "" {
t.Error("Params[0].Reason must explain the rejection")
}
if !strings.Contains(verr.Message, "--rang") {
t.Errorf("message should name the offending flag, got %q", verr.Message)
}
// The ranked candidate rides on the param as a machine-readable suggestion
// so an agent can retry without parsing prose.
if len(verr.Params) == 1 {
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--range" {
found = true
}
}
if !found {
t.Errorf("Params[0].Suggestions should include --range, got %v", verr.Params[0].Suggestions)
}
}
// The same candidate is also carried in the human-facing hint.
if !strings.Contains(verr.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", verr.Hint)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
// structured param, generic --help hint (no "did you mean" suggestion).
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if verr.Param != "" || len(verr.Params) != 0 {
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
}
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/deprecation"
)
// composePendingNotice must surface a deprecated-command alias under the
// "deprecated_command" key, with the migration target and a skill-update hint,
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
// without ever reading --help.
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(&deprecation.Notice{
Command: "+read",
Replacement: "+cells-get",
Skill: "lark-sheets",
})
got := composePendingNotice()
if got == nil {
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
}
entry, ok := got["deprecated_command"].(map[string]interface{})
if !ok {
t.Fatalf("missing deprecated_command key: %#v", got)
}
if entry["command"] != "+read" {
t.Errorf("command = %v, want +read", entry["command"])
}
if entry["replacement"] != "+cells-get" {
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
}
if entry["skill"] != "lark-sheets" {
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
}
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
t.Errorf("message missing skill-update hint: %q", msg)
}
}
// With nothing pending, the provider returns nil so no "_notice" field is
// emitted on a clean run.
func TestComposePendingNoticeEmpty(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
if got := composePendingNotice(); got != nil {
// update/skills pending are process-global; only assert the absence of
// our own key to stay robust against unrelated pending state.
if _, ok := got["deprecated_command"]; ok {
t.Fatalf("deprecated_command present after clear: %#v", got)
}
}
}

View File

@@ -36,71 +36,47 @@ const userPolicyFileName = "policy.yml"
// pluginRules carries Plugin.Restrict() contributions collected from // pluginRules carries Plugin.Restrict() contributions collected from
// the InstallAll phase; nil/empty is fine. // the InstallAll phase; nil/empty is fine.
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error { func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
// Plugin rules shadow the yaml source entirely (Resolve: plugin > yamlPath, err := userPolicyPath()
// yaml). When a plugin contributed rules we therefore do NOT even if err != nil {
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy // No user home dir means we cannot locate the policy. Treat
// error once a plugin is present, so reading a malformed yaml here // the same as "file missing": no pruning, no error. This keeps
// would let an unrelated broken file on the user's machine abort a // non-interactive CI environments (no HOME set) running.
// plugin-governed binary -- exactly the file the plugin is supposed yamlPath = ""
// to shadow. Skipping the read keeps the shadow contract honest.
var (
yamlRules []*platform.Rule
yamlPath string
)
if len(pluginRules) == 0 {
p, perr := userPolicyPath()
if perr != nil {
// No user home dir means we cannot locate the policy. Treat
// the same as "file missing": no pruning, no error. This keeps
// non-interactive CI environments (no HOME set) running.
p = ""
}
yamlPath = p
loaded, lerr := cmdpolicy.LoadYAMLPolicy(yamlPath)
if lerr != nil {
// Yaml-only failures are fail-OPEN at the caller (warn and
// continue), but the active-policy snapshot is process-global
// and may still carry data from a previous build in long-lived
// embedders / tests. Clear it explicitly so `config policy
// show` reports "no policy" instead of a stale rule that
// doesn't reflect the current command tree.
cmdpolicy.SetActive(nil)
return lerr
}
yamlRules = loaded
} }
rules, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{ yamlRule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
if err != nil {
// Yaml-only failures are fail-OPEN at the caller (warn and
// continue), but the active-policy snapshot is process-global
// and may still carry data from a previous build in long-lived
// embedders / tests. Clear it explicitly so `config policy
// show` reports "no policy" instead of a stale rule that
// doesn't reflect the current command tree.
cmdpolicy.SetActive(nil)
return err
}
rule, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: pluginRules, PluginRules: pluginRules,
YAMLRules: yamlRules, YAMLRule: yamlRule,
YAMLPath: yamlPath, YAMLPath: yamlPath,
}) })
if err != nil { if err != nil {
cmdpolicy.SetActive(nil) cmdpolicy.SetActive(nil)
return err return err
} }
if len(rules) == 0 { if rule == nil {
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source}) cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
return nil return nil
} }
// RuleName attributes a denial to a specific rule in the envelope. engine := cmdpolicy.New(rule)
// With a single rule that is unambiguous and preserves the legacy
// envelope verbatim; with several rules a denial means "no rule
// granted it", which has no single owner, so the field is left empty
// and reason_code=no_matching_rule carries the meaning instead.
ruleName := ""
if len(rules) == 1 {
ruleName = rules[0].Name
}
engine := cmdpolicy.NewSet(rules)
decisions := engine.EvaluateAll(rootCmd) decisions := engine.EvaluateAll(rootCmd)
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName) denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
cmdpolicy.Apply(rootCmd, denied) cmdpolicy.Apply(rootCmd, denied)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{ cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rules: rules, Rule: rule,
Source: source, Source: source,
DeniedPaths: len(denied), DeniedPaths: len(denied),
}) })

View File

@@ -9,14 +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/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
) )
@@ -104,7 +100,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 +125,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).
@@ -202,39 +184,6 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
} }
} }
// When a plugin contributed rules, a malformed user policy.yml must NOT
// abort: plugin rules shadow yaml entirely, so the broken file is never
// read. Regression -- previously LoadYAMLPolicy ran first and an
// unrelated broken yaml on the user's machine could fatal a
// plugin-governed binary (build.go fail-CLOSES on policy errors when a
// plugin is present).
func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
cfgDir := tmpHome(t)
t.Cleanup(cmdpolicy.ResetActiveForTesting)
writePolicy(t, cfgDir, "::: not yaml :::") // broken on purpose
pluginRules := []cmdpolicy.PluginRule{
{PluginName: "secaudit", Rule: &platform.Rule{
Name: "docs-only",
Allow: []string{"docs/**"},
MaxRisk: "write",
}},
}
root := fakeTree(t)
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
}
// Plugin rule actually applied: im/+send is outside docs/** -> hidden.
if send := findLeaf(t, root, "im", "+send"); !send.Hidden {
t.Errorf("im/+send should be hidden by plugin rule (not in docs/** allow)")
}
// docs/+update is within allow and at/below max_risk -> stays visible.
if update := findLeaf(t, root, "docs", "+update"); update.Hidden {
t.Errorf("docs/+update should remain visible under plugin rule")
}
}
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside // Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
// Resolve and produces an error. This is the safety contract: a typo in // Resolve and produces an error. This is the safety contract: a typo in
// the rule must not silently lower the pruning bar. // the rule must not silently lower the pruning bar.

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,10 +12,8 @@ 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/output" "github.com/larksuite/cli/internal/output"
) )
@@ -42,7 +40,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)") cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)")
cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin") cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin")
cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark") cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark")
cmd.Flags().StringVar(&lang, "lang", "", "language preference (e.g. zh or zh_cn)") cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding") cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
_ = cmd.MarkFlagRequired("name") _ = cmd.MarkFlagRequired("name")
@@ -54,70 +52,51 @@ 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)
if err != nil {
return err
}
lang = string(langPref)
// 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)
@@ -136,7 +115,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
AppId: appID, AppId: appID,
AppSecret: secret, AppSecret: secret,
Brand: parsedBrand, Brand: parsedBrand,
Lang: i18n.Lang(lang), Lang: lang,
Users: []core.AppUser{}, Users: []core.AppUser{},
}) })
@@ -148,7 +127,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,10 +11,8 @@ 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/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/internal/vfs"
) )
@@ -51,66 +49,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:
// short codes and Feishu locales both canonicalize to the same stored locale,
// empty stores no preference, and an unrecognized value errors.
func TestProfileAddRun_Lang(t *testing.T) {
t.Run("short and locale canonicalize and persist alike", func(t *testing.T) {
for _, in := range []string{"ja", "ja_jp"} {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
if err := profileAddRun(f, "p", "app-p", true, "feishu", in, false); err != nil {
t.Fatalf("--lang %q: profileAddRun() error = %v", in, err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if app := saved.FindApp("p"); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("--lang %q: stored Lang = %v, want %q", in, app, i18n.LangJaJP)
}
}
})
t.Run("empty stores no preference", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
if err := profileAddRun(f, "p", "app-p", true, "feishu", "", false); err != nil {
t.Fatalf("profileAddRun() error = %v", err)
}
saved, _ := core.LoadMultiAppConfig()
if app := saved.FindApp("p"); app == nil || app.Lang != "" {
t.Errorf("stored Lang = %v, want \"\" (unset)", app)
}
})
t.Run("invalid lang errors", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "p", "app-p", true, "feishu", "ZH", false)
if err == nil {
t.Fatal("expected validation error for --lang ZH, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) || output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("expected typed validation error with ExitValidation, got %T: %v", err, err)
}
})
} }
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) { func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
@@ -417,226 +355,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))

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