mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +08:00
Compare commits
99 Commits
feat/batch
...
feat/sessi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94032da862 | ||
|
|
d44c70f881 | ||
|
|
425122e611 | ||
|
|
80fade789b | ||
|
|
cc5cec32dc | ||
|
|
251de0f609 | ||
|
|
d7156b327e | ||
|
|
a3c97572b6 | ||
|
|
978b711663 | ||
|
|
d22d8473a2 | ||
|
|
4c94bdca27 | ||
|
|
29d5b95971 | ||
|
|
fec11926be | ||
|
|
816a6939f2 | ||
|
|
04edca5717 | ||
|
|
a90715f5a9 | ||
|
|
f13cee23a2 | ||
|
|
e3cea8936e | ||
|
|
f91e8bec54 | ||
|
|
0c5aae42b4 | ||
|
|
bf8c41225d | ||
|
|
0c51792f77 | ||
|
|
e30869d015 | ||
|
|
a3e78daff9 | ||
|
|
8abd39b18e | ||
|
|
b76dc18c2f | ||
|
|
85679d4258 | ||
|
|
1ba4f3973c | ||
|
|
c45ff569c4 | ||
|
|
a1506cdffb | ||
|
|
3595356ea1 | ||
|
|
73be1d06ec | ||
|
|
cccf025599 | ||
|
|
7db899db01 | ||
|
|
c2d6038aae | ||
|
|
efa3439e01 | ||
|
|
9f150670f3 | ||
|
|
578e2db4e0 | ||
|
|
94139751d3 | ||
|
|
8c3ed5d224 | ||
|
|
c982df4cf0 | ||
|
|
fb5ae41bca | ||
|
|
87e872a4c1 | ||
|
|
ddc0f2a521 | ||
|
|
440867f1b4 | ||
|
|
d0cde9a414 | ||
|
|
075b34f9a3 | ||
|
|
3788405256 | ||
|
|
462358a746 | ||
|
|
ad4d3cb874 | ||
|
|
171778951d | ||
|
|
a6797ac2e4 | ||
|
|
d852ab311b | ||
|
|
e8bfbab4a5 | ||
|
|
3bda9e17de | ||
|
|
e753b15d84 | ||
|
|
bdffffb368 | ||
|
|
ec6fdc9b30 | ||
|
|
775ee5a501 | ||
|
|
214318aa02 | ||
|
|
6f2cddfce1 | ||
|
|
75926f9744 | ||
|
|
5c4ad52741 | ||
|
|
3fcb695698 | ||
|
|
fb042758db | ||
|
|
22108c3300 | ||
|
|
31744f8cf9 | ||
|
|
1dd0758091 | ||
|
|
4a5a669b1a | ||
|
|
ebb0b6fe73 | ||
|
|
5c0a36b2a6 | ||
|
|
21905b0ba1 | ||
|
|
602c788fd9 | ||
|
|
30b28cf17f | ||
|
|
297776ea66 | ||
|
|
5b0c3137e3 | ||
|
|
4c31323de1 | ||
|
|
8a268aa2d2 | ||
|
|
39d60cb706 | ||
|
|
d9330b7ab3 | ||
|
|
6b833257c7 | ||
|
|
ba51d4874e | ||
|
|
40a09c8957 | ||
|
|
806e8679f6 | ||
|
|
d69761e205 | ||
|
|
7346de30b1 | ||
|
|
cf93ee051c | ||
|
|
fe32a6e0a9 | ||
|
|
af9835c288 | ||
|
|
2e3073a532 | ||
|
|
1c92ed8841 | ||
|
|
644c3c77dd | ||
|
|
bd898a1d74 | ||
|
|
898e6d4b3b | ||
|
|
7df37ed715 | ||
|
|
3f9ace8af5 | ||
|
|
b3514e5519 | ||
|
|
b46e60c156 | ||
|
|
d71bab0061 |
49
.github/workflows/ci.yml
vendored
49
.github/workflows/ci.yml
vendored
@@ -5,6 +5,7 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
types: [opened, synchronize, reopened, edited]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -70,6 +71,7 @@ jobs:
|
|||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
@@ -87,6 +89,23 @@ jobs:
|
|||||||
- name: Run errs/ lint guards (lintcheck)
|
- name: Run errs/ lint guards (lintcheck)
|
||||||
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
|
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
|
||||||
|
|
||||||
|
script-test:
|
||||||
|
needs: fast-gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- name: Run script tests
|
||||||
|
run: make script-test
|
||||||
|
|
||||||
deterministic-gate:
|
deterministic-gate:
|
||||||
needs: fast-gate
|
needs: fast-gate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -109,8 +128,28 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
|
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"
|
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
||||||
|
- name: Write public content metadata
|
||||||
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
|
env:
|
||||||
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
|
PR_BODY: ${{ github.event.pull_request.body }}
|
||||||
|
PR_BRANCH: ${{ github.head_ref }}
|
||||||
|
run: |
|
||||||
|
mkdir -p .tmp/quality-gate
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump({
|
||||||
|
"title": os.environ.get("PR_TITLE", ""),
|
||||||
|
"body": os.environ.get("PR_BODY", ""),
|
||||||
|
"branch": os.environ.get("PR_BRANCH", ""),
|
||||||
|
}, f)
|
||||||
|
f.write("\n")
|
||||||
|
PY
|
||||||
- name: Run CLI deterministic gate
|
- name: Run CLI deterministic gate
|
||||||
run: make quality-gate
|
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
|
||||||
- name: Upload quality gate facts
|
- name: Upload quality gate facts
|
||||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
@@ -220,7 +259,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, script-test, deterministic-gate]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
@@ -241,7 +280,7 @@ 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, script-test, deterministic-gate]
|
||||||
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:
|
permissions:
|
||||||
@@ -333,7 +372,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, script-test, deterministic-gate, 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,6 +384,7 @@ 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 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.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
|
||||||
@@ -361,6 +401,7 @@ jobs:
|
|||||||
"${{ needs.fast-gate.result }}" \
|
"${{ needs.fast-gate.result }}" \
|
||||||
"${{ needs.unit-test.result }}" \
|
"${{ needs.unit-test.result }}" \
|
||||||
"${{ needs.lint.result }}" \
|
"${{ needs.lint.result }}" \
|
||||||
|
"${{ needs.script-test.result }}" \
|
||||||
"${{ needs.deterministic-gate.result }}" \
|
"${{ needs.deterministic-gate.result }}" \
|
||||||
"${{ needs.coverage.result }}" \
|
"${{ needs.coverage.result }}" \
|
||||||
"${{ needs.deadcode.result }}" \
|
"${{ needs.deadcode.result }}" \
|
||||||
|
|||||||
28
.github/workflows/comment-audit.yml
vendored
Normal file
28
.github/workflows/comment-audit.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
name: Comment Audit
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created, edited]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted, edited]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created, edited]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
public-content-comment-audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
- name: Post-publication comment audit
|
||||||
|
run: |
|
||||||
|
mkdir -p .tmp/comment-audit
|
||||||
|
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
|
||||||
|
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"
|
||||||
77
.github/workflows/semantic-review.yml
vendored
77
.github/workflows/semantic-review.yml
vendored
@@ -88,31 +88,44 @@ jobs:
|
|||||||
commit_sha: targetHeadSha,
|
commit_sha: targetHeadSha,
|
||||||
});
|
});
|
||||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||||
candidate.state === "open" &&
|
|
||||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||||
candidate.head?.sha === targetHeadSha
|
candidate.head?.sha === targetHeadSha
|
||||||
);
|
);
|
||||||
if (candidatePRs.length > 1) {
|
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
|
if (openCandidatePRs.length > 1) {
|
||||||
|
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||||
}
|
}
|
||||||
if (candidatePRs.length === 1) {
|
if (openCandidatePRs.length === 1) {
|
||||||
prNumber = candidatePRs[0].number;
|
prNumber = openCandidatePRs[0].number;
|
||||||
|
} else if (candidatePRs.length > 0) {
|
||||||
|
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||||
|
core.setOutput("stale", "true");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!prNumber) {
|
if (!prNumber) {
|
||||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
state: "open",
|
state: "all",
|
||||||
per_page: 100,
|
per_page: 100,
|
||||||
}).then((prs) => prs.filter((candidate) =>
|
}).then((prs) => prs.filter((candidate) =>
|
||||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||||
candidate.head?.sha === targetHeadSha
|
candidate.head?.sha === targetHeadSha
|
||||||
));
|
));
|
||||||
if (candidatePRs.length !== 1) {
|
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||||
|
if (openCandidatePRs.length > 1) {
|
||||||
|
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||||
|
}
|
||||||
|
if (openCandidatePRs.length === 1) {
|
||||||
|
prNumber = openCandidatePRs[0].number;
|
||||||
|
} else if (candidatePRs.length > 0) {
|
||||||
|
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||||
|
core.setOutput("stale", "true");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
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");
|
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||||
const { data: pr } = await github.rest.pulls.get({
|
const { data: pr } = await github.rest.pulls.get({
|
||||||
@@ -121,6 +134,11 @@ jobs:
|
|||||||
pull_number: prNumber,
|
pull_number: prNumber,
|
||||||
});
|
});
|
||||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||||
|
if (pr.state !== "open") {
|
||||||
|
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||||
|
core.setOutput("stale", "true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pr.head.sha !== targetHeadSha) {
|
if (pr.head.sha !== targetHeadSha) {
|
||||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
|
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
|
||||||
core.setOutput("stale", "true");
|
core.setOutput("stale", "true");
|
||||||
@@ -299,31 +317,44 @@ jobs:
|
|||||||
commit_sha: targetHeadSha,
|
commit_sha: targetHeadSha,
|
||||||
});
|
});
|
||||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||||
candidate.state === "open" &&
|
|
||||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||||
candidate.head?.sha === targetHeadSha
|
candidate.head?.sha === targetHeadSha
|
||||||
);
|
);
|
||||||
if (candidatePRs.length > 1) {
|
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
|
if (openCandidatePRs.length > 1) {
|
||||||
|
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||||
}
|
}
|
||||||
if (candidatePRs.length === 1) {
|
if (openCandidatePRs.length === 1) {
|
||||||
prNumber = candidatePRs[0].number;
|
prNumber = openCandidatePRs[0].number;
|
||||||
|
} else if (candidatePRs.length > 0) {
|
||||||
|
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||||
|
core.setOutput("stale", "true");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!prNumber) {
|
if (!prNumber) {
|
||||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
state: "open",
|
state: "all",
|
||||||
per_page: 100,
|
per_page: 100,
|
||||||
}).then((prs) => prs.filter((candidate) =>
|
}).then((prs) => prs.filter((candidate) =>
|
||||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||||
candidate.head?.sha === targetHeadSha
|
candidate.head?.sha === targetHeadSha
|
||||||
));
|
));
|
||||||
if (candidatePRs.length !== 1) {
|
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||||
|
if (openCandidatePRs.length > 1) {
|
||||||
|
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||||
|
}
|
||||||
|
if (openCandidatePRs.length === 1) {
|
||||||
|
prNumber = openCandidatePRs[0].number;
|
||||||
|
} else if (candidatePRs.length > 0) {
|
||||||
|
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||||
|
core.setOutput("stale", "true");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
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");
|
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||||
const { data: pr } = await github.rest.pulls.get({
|
const { data: pr } = await github.rest.pulls.get({
|
||||||
@@ -332,6 +363,16 @@ jobs:
|
|||||||
pull_number: prNumber,
|
pull_number: prNumber,
|
||||||
});
|
});
|
||||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||||
|
if (pr.state !== "open") {
|
||||||
|
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||||
|
core.setOutput("stale", "true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pr.head.repo) {
|
||||||
|
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
|
||||||
|
core.setOutput("stale", "true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pr.head.sha !== targetHeadSha) {
|
if (pr.head.sha !== targetHeadSha) {
|
||||||
core.notice("semantic review skipped: workflow_run is stale for this PR head");
|
core.notice("semantic review skipped: workflow_run is stale for this PR head");
|
||||||
core.setOutput("stale", "true");
|
core.setOutput("stale", "true");
|
||||||
@@ -389,6 +430,10 @@ jobs:
|
|||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
pull_number: pr,
|
pull_number: pr,
|
||||||
});
|
});
|
||||||
|
if (pull.state !== "open") {
|
||||||
|
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pull.head.sha !== headSha) {
|
if (pull.head.sha !== headSha) {
|
||||||
core.notice("semantic review skipped infrastructure failure check: PR head changed");
|
core.notice("semantic review skipped infrastructure failure check: PR head changed");
|
||||||
return;
|
return;
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -7,6 +7,11 @@ bin/
|
|||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# Python (skill-bundled helper scripts)
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -22,6 +27,9 @@ Thumbs.db
|
|||||||
# Go
|
# Go
|
||||||
docs/ref
|
docs/ref
|
||||||
docs/
|
docs/
|
||||||
|
!tests/cli_e2e/docs/
|
||||||
|
!tests/cli_e2e/docs/*.go
|
||||||
|
!tests/cli_e2e/docs/*.md
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
|
|
||||||
@@ -46,3 +54,4 @@ app.log
|
|||||||
cover*.out
|
cover*.out
|
||||||
|
|
||||||
lark-env.sh
|
lark-env.sh
|
||||||
|
/automations/
|
||||||
|
|||||||
142
CHANGELOG.md
142
CHANGELOG.md
@@ -2,6 +2,141 @@
|
|||||||
|
|
||||||
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.65] - 2026-07-03
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **drive**: Document 30-char query limit for `+search` (#1560)
|
||||||
|
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
|
||||||
|
- **doc**: Sync lark-doc skill content from online-doc (#1701)
|
||||||
|
|
||||||
|
## [v1.0.64] - 2026-07-02
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
|
||||||
|
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
|
||||||
|
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
|
||||||
|
- **cli**: Improve secure label error handling (#1707)
|
||||||
|
- **cli**: Reduce public content token false positives
|
||||||
|
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
|
||||||
|
- **doc**: Align word statistics compound tokens (#1706)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
|
||||||
|
- **doc**: Support `reference_map` in docs (#1690)
|
||||||
|
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
|
||||||
|
|
||||||
|
## [v1.0.62] - 2026-07-01
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **vc**: Add meeting message send shortcut (#1643)
|
||||||
|
- **doc**: Add document word statistics helper (#1697)
|
||||||
|
- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498)
|
||||||
|
- **install**: Fail closed when `checksums.txt` is missing during install (#1503)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **drive**: Improve batch failure handling for push/pull/sync (#1703)
|
||||||
|
- **base**: Support JSON array input for field create (#1661)
|
||||||
|
- **task**: Expose completion state in `my tasks` output (#1641)
|
||||||
|
- **cli**: Reduce public content credential false positives (#1700)
|
||||||
|
|
||||||
|
## [v1.0.61] - 2026-06-30
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
|
||||||
|
- **identity**: Add `whoami` command showing effective identity (#1666)
|
||||||
|
- **docs**: Add reference map flags (#1547)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **identity**: Correct identity diagnosis under external credential providers (#1693)
|
||||||
|
- **cli**: Harden git credential error handling (#1676)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **doc**: Guide document copy skill usage (#1673)
|
||||||
|
- **doc**: Fix lark-doc media token examples (#1662)
|
||||||
|
|
||||||
|
## [v1.0.60] - 2026-06-29
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **affordance**: Per-command usage guidance system with markdown source (#1565)
|
||||||
|
- **event**: Support VC meeting lifecycle events (#1632)
|
||||||
|
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
|
||||||
|
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
|
||||||
|
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
- **ci**: Reduce public content false positives
|
||||||
|
|
||||||
|
## [v1.0.59] - 2026-06-26
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
|
||||||
|
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
|
||||||
|
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **docs**: Hide docs `api-version` compat flag (#1580)
|
||||||
|
|
||||||
|
## [v1.0.58] - 2026-06-25
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
|
||||||
|
- **base**: Add Base URL and title resolve shortcuts (#1338)
|
||||||
|
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
|
||||||
|
- **doc**: Support `create` title option (#1536)
|
||||||
|
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
|
||||||
|
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
|
||||||
|
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
|
||||||
|
- **task**: Add task event consumer (#1510)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **doc**: Prefix docs resource shortcuts (#1564)
|
||||||
|
- **binding**: Skip unix mode audit on Windows (#1525)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **approval**: Sync approval skill for meta API commands (#1499)
|
||||||
|
- **doc**: Restore lark-doc style requirements (#1579)
|
||||||
|
- **im**: Document `chat.nickname` get/update/delete (#1378)
|
||||||
|
- **im**: Clarify audio message opus requirement (#1271)
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
- **ci**: Add public content safeguards and reduce false positives
|
||||||
|
|
||||||
## [v1.0.57] - 2026-06-23
|
## [v1.0.57] - 2026-06-23
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -1236,6 +1371,13 @@ 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.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||||
|
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||||
|
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||||
|
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||||
|
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||||
|
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
|
||||||
|
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
[v1.0.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.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.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -12,6 +12,7 @@ QUALITY_GATE_DIR ?= .tmp/quality-gate
|
|||||||
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
|
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
|
||||||
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
|
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
|
||||||
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
|
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
|
||||||
|
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
|
||||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||||
PREFIX ?= /usr/local
|
PREFIX ?= /usr/local
|
||||||
|
|
||||||
@@ -69,7 +70,8 @@ integration-test: build
|
|||||||
test: vet fmt-check script-test unit-test examples-build integration-test
|
test: vet fmt-check script-test unit-test examples-build integration-test
|
||||||
|
|
||||||
quality-gate: build
|
quality-gate: build
|
||||||
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
|
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
|
||||||
|
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
|
||||||
LARKSUITE_CLI_REMOTE_META=off \
|
LARKSUITE_CLI_REMOTE_META=off \
|
||||||
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
|
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
|
||||||
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
|
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
|
||||||
@@ -89,6 +91,7 @@ quality-gate: build
|
|||||||
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
|
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
|
||||||
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
|
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
|
||||||
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
|
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
|
||||||
|
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
|
||||||
--facts-out $(QUALITY_GATE_FACTS_OUT)
|
--facts-out $(QUALITY_GATE_FACTS_OUT)
|
||||||
|
|
||||||
install: build
|
install: build
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
|
|||||||
```bash
|
```bash
|
||||||
lark-cli calendar +agenda
|
lark-cli calendar +agenda
|
||||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `lark-cli <service> --help` to see all shortcut commands.
|
Run `lark-cli <service> --help` to see all shortcut commands.
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
|
|||||||
```bash
|
```bash
|
||||||
lark-cli calendar +agenda
|
lark-cli calendar +agenda
|
||||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||||
```
|
```
|
||||||
|
|
||||||
运行 `lark-cli <service> --help` 查看所有快捷命令。
|
运行 `lark-cli <service> --help` 查看所有快捷命令。
|
||||||
|
|||||||
49
affordance/README.md
Normal file
49
affordance/README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Affordance
|
||||||
|
|
||||||
|
Per-command usage guidance for the CLI, authored as one markdown file per domain
|
||||||
|
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
|
||||||
|
`schema` output, and read directly at runtime (lazy, cached) — there is no build
|
||||||
|
step. Maintain these files alongside `skills/` and `shortcuts/`.
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
A small, fixed markdown subset; each file describes one domain:
|
||||||
|
|
||||||
|
# <domain> optional `> skill: <name>` applies to every command below
|
||||||
|
## <command> the command as typed, minus `lark-cli <domain>`
|
||||||
|
<lead paragraph> when to use this command
|
||||||
|
### Avoid when when not to use it / which command to use instead
|
||||||
|
### Prerequisites what you must have first (e.g. an id, and where it comes from)
|
||||||
|
### Tips gotchas and constraints
|
||||||
|
### Examples **description** lines, each followed by a fenced command
|
||||||
|
### <other heading> a custom section; flows through verbatim
|
||||||
|
|
||||||
|
Reference another command with `[[command]]` — it renders as `command` in help.
|
||||||
|
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
||||||
|
("… from [[command]]") it means "get the input there first".
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
## messages get
|
||||||
|
Fetch the full content of a single message by id.
|
||||||
|
|
||||||
|
### Avoid when
|
||||||
|
- Reading several at once → use [[messages batch_get]]
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- message_id from [[messages list]]
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Fetch one message**
|
||||||
|
```bash
|
||||||
|
lark-cli mail user_mailbox.messages get --message-id "<id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
|
||||||
|
- Keep it concise and high-signal — don't restate field/flag names, id types, or
|
||||||
|
anything the schema and flags already show; the agent infers the rest.
|
||||||
|
- Command-form headings resolve to method ids via the registry, so plural resource
|
||||||
|
names (`messages`) map to the singular method id (`message`) automatically.
|
||||||
19
affordance/contact.md
Normal file
19
affordance/contact.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# contact
|
||||||
|
> skill: lark-contact
|
||||||
|
|
||||||
|
## user_profiles batch_query
|
||||||
|
Bulk-fetch personal status and signature for user ids you already have.
|
||||||
|
|
||||||
|
### Avoid when
|
||||||
|
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
- Off by default — set include_personal_status / include_description to true under query_option
|
||||||
|
- ids in user_ids must match --user-id-type (default open_id)
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Bulk-query status and signature**
|
||||||
|
```bash
|
||||||
|
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
|
||||||
|
```
|
||||||
@@ -67,8 +67,21 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
|||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "api <method> <path>",
|
Use: "api <method> <path>",
|
||||||
Short: "Generic Lark API requests",
|
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
|
||||||
Args: cobra.ExactArgs(2),
|
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
|
||||||
|
|
||||||
|
Prefer the typed domain command when one exists — it validates parameters,
|
||||||
|
shows the Risk level, gates destructive calls behind --yes, and carries usage
|
||||||
|
guidance that this raw command does not. If a domain command covers your task
|
||||||
|
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
|
||||||
|
|
||||||
|
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
|
||||||
|
newer/preview APIs), where you already have the HTTP path from the Lark docs.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||||
|
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
opts.Method = strings.ToUpper(args[0])
|
opts.Method = strings.ToUpper(args[0])
|
||||||
opts.Path = args[1]
|
opts.Path = args[1]
|
||||||
|
|||||||
@@ -20,13 +20,28 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
||||||
|
cmd := NewCmdApi(f, runF)
|
||||||
|
cmd.SilenceErrors = true
|
||||||
|
cmd.SilenceUsage = true
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestRootCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "lark-cli",
|
||||||
|
SilenceErrors: true,
|
||||||
|
SilenceUsage: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApiCmd_FlagParsing(t *testing.T) {
|
func TestApiCmd_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,
|
||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||||
@@ -98,7 +113,7 @@ func TestApiCmd_BotMode(t *testing.T) {
|
|||||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET"}) // missing path
|
cmd.SetArgs([]string{"GET"}) // missing path
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
fn := cmd.ValidArgsFunction
|
fn := cmd.ValidArgsFunction
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
flag := cmd.Flags().Lookup("as")
|
flag := cmd.Flags().Lookup("as")
|
||||||
if flag == nil {
|
if flag == nil {
|
||||||
t.Fatal("expected --as flag to be registered")
|
t.Fatal("expected --as flag to be registered")
|
||||||
@@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
@@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
|||||||
ContentType: "application/octet-stream",
|
ContentType: "application/octet-stream",
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -328,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -368,7 +383,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
// Should return an error
|
// Should return an error
|
||||||
@@ -409,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -448,7 +463,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -483,7 +498,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := root.Execute()
|
err := root.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||||
@@ -791,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||||
@@ -830,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||||
@@ -859,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(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,
|
||||||
})
|
})
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||||
@@ -934,7 +949,7 @@ func TestApiCmd_FileWithGET(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,
|
||||||
})
|
})
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||||
@@ -951,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(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,
|
||||||
})
|
})
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||||
@@ -974,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
|||||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1015,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "status",
|
Use: "status",
|
||||||
Short: "View current auth status",
|
Short: "View current auth status",
|
||||||
|
Long: `Show OAuth user login, token validity, and granted scopes.
|
||||||
|
For token-validity checks, run lark-cli auth status --json --verify.
|
||||||
|
This is not profile/app selection diagnostics; use lark-cli whoami for the effective app/profile identity used by an invocation.`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if runF != nil {
|
if runF != nil {
|
||||||
return runF(opts)
|
return runF(opts)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
@@ -13,6 +14,20 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestAuthStatusHelpDistinguishesFromWhoami(t *testing.T) {
|
||||||
|
cmd := NewCmdAuthStatus(nil, nil)
|
||||||
|
for _, want := range []string{
|
||||||
|
"OAuth user login",
|
||||||
|
"auth status --json --verify",
|
||||||
|
"not profile/app selection diagnostics",
|
||||||
|
"lark-cli whoami",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(cmd.Long, want) {
|
||||||
|
t.Errorf("auth status --help Long missing %q; got:\n%s", want, cmd.Long)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,5 +28,13 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
|
|||||||
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
|
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
|
||||||
return cmdutil.InvocationContext{}, err
|
return cmdutil.InvocationContext{}, err
|
||||||
}
|
}
|
||||||
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
|
|
||||||
|
profileFromFlag := globals.Profile != ""
|
||||||
|
if !profileFromFlag {
|
||||||
|
globals.Profile = os.Getenv(envvars.CliProfile)
|
||||||
|
}
|
||||||
|
return cmdutil.InvocationContext{
|
||||||
|
Profile: globals.Profile,
|
||||||
|
ProfileFromFlag: profileFromFlag,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
|
)
|
||||||
|
|
||||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||||
@@ -70,3 +74,45 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
|
|||||||
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBootstrapProfileEnvFallback(t *testing.T) {
|
||||||
|
t.Run("flag wins over env", func(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliProfile, "tenant_env")
|
||||||
|
inv, err := BootstrapInvocationContext([]string{"--profile", "tenant_flag", "whoami"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if inv.Profile != "tenant_flag" {
|
||||||
|
t.Errorf("got %q, want tenant_flag", inv.Profile)
|
||||||
|
}
|
||||||
|
if !inv.ProfileFromFlag {
|
||||||
|
t.Errorf("ProfileFromFlag = false, want true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("env used when flag absent", func(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliProfile, "tenant_env")
|
||||||
|
inv, err := BootstrapInvocationContext([]string{"whoami"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if inv.Profile != "tenant_env" {
|
||||||
|
t.Errorf("got %q, want tenant_env", inv.Profile)
|
||||||
|
}
|
||||||
|
if inv.ProfileFromFlag {
|
||||||
|
t.Errorf("ProfileFromFlag = true, want false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("empty when neither set", func(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliProfile, "")
|
||||||
|
inv, err := BootstrapInvocationContext([]string{"whoami"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if inv.Profile != "" {
|
||||||
|
t.Errorf("got %q, want empty", inv.Profile)
|
||||||
|
}
|
||||||
|
if inv.ProfileFromFlag {
|
||||||
|
t.Errorf("ProfileFromFlag = true, want false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
11
cmd/build.go
11
cmd/build.go
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/larksuite/cli/cmd/service"
|
"github.com/larksuite/cli/cmd/service"
|
||||||
"github.com/larksuite/cli/cmd/skill"
|
"github.com/larksuite/cli/cmd/skill"
|
||||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||||
|
"github.com/larksuite/cli/cmd/whoami"
|
||||||
_ "github.com/larksuite/cli/events"
|
_ "github.com/larksuite/cli/events"
|
||||||
"github.com/larksuite/cli/internal/apicatalog"
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
@@ -170,6 +171,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
rootCmd.SetOut(cfg.streams.Out)
|
rootCmd.SetOut(cfg.streams.Out)
|
||||||
rootCmd.SetErr(cfg.streams.ErrOut)
|
rootCmd.SetErr(cfg.streams.ErrOut)
|
||||||
|
|
||||||
|
// Root-only usage template (curated Usage synopsis + skills footer); see
|
||||||
|
// rootUsageTemplate.
|
||||||
|
rootCmd.SetUsageTemplate(rootUsageTemplate)
|
||||||
|
|
||||||
installTipsHelpFunc(rootCmd)
|
installTipsHelpFunc(rootCmd)
|
||||||
rootCmd.SilenceErrors = true
|
rootCmd.SilenceErrors = true
|
||||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||||
@@ -190,6 +195,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||||
|
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||||
@@ -205,7 +211,12 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
}
|
}
|
||||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||||
|
|
||||||
|
groupRootCommands(rootCmd)
|
||||||
|
|
||||||
installUnknownSubcommandGuard(rootCmd)
|
installUnknownSubcommandGuard(rootCmd)
|
||||||
|
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
|
||||||
|
// before printing help; non-bare invocations and non-TTY are unaffected.
|
||||||
|
installRootUpgradePrompt(f, rootCmd)
|
||||||
|
|
||||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||||
pruneForStrictMode(rootCmd, mode)
|
pruneForStrictMode(rootCmd, mode)
|
||||||
|
|||||||
@@ -84,6 +84,16 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigShowHelpClarifiesSavedConfig(t *testing.T) {
|
||||||
|
cmd := NewCmdConfigShow(nil, nil)
|
||||||
|
if !strings.Contains(cmd.Short, "saved config") {
|
||||||
|
t.Errorf("config show short = %q, want saved config", cmd.Short)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
|
||||||
|
t.Errorf("config show help missing whoami route")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
|
|||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "show",
|
Use: "show",
|
||||||
Short: "Show current configuration",
|
Short: "Show saved config",
|
||||||
|
Long: "Shows saved config. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if runF != nil {
|
if runF != nil {
|
||||||
return runF(opts)
|
return runF(opts)
|
||||||
|
|||||||
@@ -129,7 +129,10 @@ func doctorRun(opts *DoctorOptions) error {
|
|||||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||||
} else {
|
} else {
|
||||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
// No hint: this only summarizes the two checks above, which already carry
|
||||||
|
// the source-appropriate remediation. A command here would be redundant,
|
||||||
|
// or wrong (`auth status` is blocked under an external provider).
|
||||||
|
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 4 & 5. Endpoint reachability ──
|
// ── 4 & 5. Endpoint reachability ──
|
||||||
|
|||||||
@@ -4,14 +4,19 @@
|
|||||||
package doctor
|
package doctor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
||||||
@@ -140,14 +145,84 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||||
|
t.Helper()
|
||||||
|
if got := findCheck(t, checks, name); got.Status != status {
|
||||||
|
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, check := range checks {
|
for _, check := range checks {
|
||||||
if check.Name == name {
|
if check.Name == name {
|
||||||
if check.Status != status {
|
return check
|
||||||
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.Fatalf("check %q not found in %#v", name, checks)
|
t.Fatalf("check %q not found in %#v", name, checks)
|
||||||
|
return checkResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeExtProvider struct {
|
||||||
|
name string
|
||||||
|
account *extcred.Account
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakeExtProvider) Name() string { return p.name }
|
||||||
|
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||||
|
return p.account, nil
|
||||||
|
}
|
||||||
|
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under an external credential provider with no usable identity, the
|
||||||
|
// identity_ready hint must not point at `auth status` (blocked there); the
|
||||||
|
// per-identity checks already carry the source-appropriate escalation.
|
||||||
|
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||||
|
CurrentApp: "default",
|
||||||
|
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider serves neither identity: bot unsupported, user supported but not
|
||||||
|
// signed in → both unavailable → identity_ready fails.
|
||||||
|
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
|
||||||
|
cred := credential.NewCredentialProvider(
|
||||||
|
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
|
||||||
|
nil, nil,
|
||||||
|
func() (*http.Client, error) { return nil, nil },
|
||||||
|
)
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
f := &cmdutil.Factory{
|
||||||
|
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||||
|
Credential: cred,
|
||||||
|
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||||
|
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Checks []checkResult `json:"checks"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
ready := findCheck(t, got.Checks, "identity_ready")
|
||||||
|
if ready.Status != "fail" {
|
||||||
|
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
|
||||||
|
}
|
||||||
|
// The summary defers to the per-identity checks; it carries no hint of its
|
||||||
|
// own (a command here would be wrong under an external provider).
|
||||||
|
if ready.Hint != "" {
|
||||||
|
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
|
||||||
|
}
|
||||||
|
user := findCheck(t, got.Checks, "user_identity")
|
||||||
|
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
|
||||||
|
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,22 @@ import (
|
|||||||
|
|
||||||
"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"
|
||||||
|
|
||||||
_ "github.com/larksuite/cli/events"
|
_ "github.com/larksuite/cli/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||||
|
for _, key := range []string{
|
||||||
|
"vc.meeting.participant_meeting_started_v1",
|
||||||
|
"vc.meeting.participant_meeting_joined_v1",
|
||||||
|
} {
|
||||||
|
if _, ok := eventlib.Lookup(key); !ok {
|
||||||
|
t.Fatalf("event.Lookup(%q) should succeed", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunList_TextOutput(t *testing.T) {
|
func TestRunList_TextOutput(t *testing.T) {
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||||
|
|
||||||
@@ -26,6 +38,9 @@ func TestRunList_TextOutput(t *testing.T) {
|
|||||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||||
"im.message.receive_v1",
|
"im.message.receive_v1",
|
||||||
"im.message.message_read_v1",
|
"im.message.message_read_v1",
|
||||||
|
"task.task.update_user_access_v2",
|
||||||
|
"vc.meeting.participant_meeting_started_v1",
|
||||||
|
"vc.meeting.participant_meeting_joined_v1",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||||
@@ -55,4 +70,31 @@ func TestRunList_JSONOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gotKeys := map[string]map[string]interface{}{}
|
||||||
|
for _, row := range rows {
|
||||||
|
if key, ok := row["key"].(string); ok {
|
||||||
|
gotKeys[key] = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var foundTask bool
|
||||||
|
for key, row := range gotKeys {
|
||||||
|
if key == "task.task.update_user_access_v2" {
|
||||||
|
foundTask = true
|
||||||
|
if row["single_consumer"] != true {
|
||||||
|
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundTask {
|
||||||
|
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"vc.meeting.participant_meeting_started_v1",
|
||||||
|
"vc.meeting.participant_meeting_joined_v1",
|
||||||
|
} {
|
||||||
|
if _, ok := gotKeys[want]; !ok {
|
||||||
|
t.Errorf("JSON list output missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,73 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||||
|
|
||||||
|
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
|
||||||
|
t.Fatalf("runSchema json: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["jq_root_path"] != ".event" {
|
||||||
|
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
|
||||||
|
}
|
||||||
|
if payload["single_consumer"] != true {
|
||||||
|
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
|
||||||
|
}
|
||||||
|
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||||
|
props := resolved["properties"].(map[string]interface{})
|
||||||
|
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||||
|
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
|
||||||
|
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||||
|
}
|
||||||
|
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
|
||||||
|
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||||
|
for _, key := range []string{
|
||||||
|
"vc.meeting.participant_meeting_started_v1",
|
||||||
|
"vc.meeting.participant_meeting_joined_v1",
|
||||||
|
} {
|
||||||
|
t.Run(key, func(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||||
|
|
||||||
|
if err := runSchema(f, key, true); err != nil {
|
||||||
|
t.Fatalf("runSchema json: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["key"] != key {
|
||||||
|
t.Errorf("key = %v, want %s", payload["key"], key)
|
||||||
|
}
|
||||||
|
resolved, ok := payload["resolved_output_schema"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload)
|
||||||
|
}
|
||||||
|
properties, ok := resolved["properties"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} {
|
||||||
|
if _, ok := properties[field]; !ok {
|
||||||
|
t.Errorf("resolved output schema missing field %q: %+v", field, properties)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := properties["end_time"]; ok {
|
||||||
|
t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||||
const syntheticKey = "test.evt_sub"
|
const syntheticKey = "test.evt_sub"
|
||||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type profileListItem struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AppID string `json:"appId"`
|
AppID string `json:"appId"`
|
||||||
Brand core.LarkBrand `json:"brand"`
|
Brand core.LarkBrand `json:"brand"`
|
||||||
Active bool `json:"active"`
|
Default bool `json:"default"`
|
||||||
User string `json:"user,omitempty"`
|
User string `json:"user,omitempty"`
|
||||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,8 @@ type profileListItem struct {
|
|||||||
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List all profiles",
|
Short: "List saved profiles",
|
||||||
|
Long: "Lists saved profiles. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return profileListRun(f)
|
return profileListRun(f)
|
||||||
},
|
},
|
||||||
@@ -53,7 +54,7 @@ func profileListRun(f *cmdutil.Factory) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
|
// Intentionally uses "" to show the saved default profile, not the ephemeral --profile override.
|
||||||
currentApp := multi.CurrentAppConfig("")
|
currentApp := multi.CurrentAppConfig("")
|
||||||
currentName := ""
|
currentName := ""
|
||||||
if currentApp != nil {
|
if currentApp != nil {
|
||||||
@@ -66,10 +67,10 @@ func profileListRun(f *cmdutil.Factory) error {
|
|||||||
name := app.ProfileName()
|
name := app.ProfileName()
|
||||||
|
|
||||||
item := profileListItem{
|
item := profileListItem{
|
||||||
Name: name,
|
Name: name,
|
||||||
AppID: app.AppId,
|
AppID: app.AppId,
|
||||||
Brand: app.Brand,
|
Brand: app.Brand,
|
||||||
Active: name == currentName,
|
Default: name == currentName,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(app.Users) > 0 {
|
if len(app.Users) > 0 {
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "profile",
|
Use: "profile",
|
||||||
Short: "Manage configuration profiles",
|
Short: "Manage configuration profiles",
|
||||||
|
Long: `Profiles are named app identities managed by lark-cli.
|
||||||
|
|
||||||
|
Profile selection:
|
||||||
|
lark-cli whoami --json Show the app/profile lark-cli is using now.
|
||||||
|
lark-cli auth status --json Verify OAuth login and token state.
|
||||||
|
--profile <name> Use a profile for this command only.
|
||||||
|
LARKSUITE_CLI_PROFILE Use a profile for the current shell / agent session.
|
||||||
|
config show / profile list Inspect saved config, not current usage.
|
||||||
|
unset LARKSUITE_CLI_PROFILE Clear the session profile and fall back to direct app env or configured default.`,
|
||||||
}
|
}
|
||||||
cmdutil.DisableAuthCheck(cmd)
|
cmdutil.DisableAuthCheck(cmd)
|
||||||
cmdutil.SetTips(cmd, []string{
|
cmdutil.SetTips(cmd, []string{
|
||||||
|
|||||||
@@ -306,14 +306,21 @@ func TestProfileListRun_OutputsProfiles(t *testing.T) {
|
|||||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||||
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
|
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
|
||||||
}
|
}
|
||||||
|
raw := stdout.String()
|
||||||
|
if strings.Contains(raw, `"active"`) {
|
||||||
|
t.Fatalf("profile list output contains legacy active field: %s", raw)
|
||||||
|
}
|
||||||
|
if !strings.Contains(raw, `"default"`) {
|
||||||
|
t.Fatalf("profile list output missing default field: %s", raw)
|
||||||
|
}
|
||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("len(got) = %d, want 2", len(got))
|
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||||
}
|
}
|
||||||
if got[0].Name != "default" || !got[0].Active {
|
if got[0].Name != "default" || !got[0].Default {
|
||||||
t.Fatalf("got[0] = %#v, want active default profile", got[0])
|
t.Fatalf("got[0] = %#v, want configured default profile", got[0])
|
||||||
}
|
}
|
||||||
if got[1].Name != "target" || got[1].Active {
|
if got[1].Name != "target" || got[1].Default {
|
||||||
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
|
t.Fatalf("got[1] = %#v, want non-default target profile", got[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,6 +634,35 @@ func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProfileHelpHasSelectionSection asserts `profile --help` documents the
|
||||||
|
// per-invocation flag and session-scoped env var for selecting a profile, so
|
||||||
|
// users and AI agents can find LARKSUITE_CLI_PROFILE without reading source.
|
||||||
|
func TestProfileHelpHasSelectionSection(t *testing.T) {
|
||||||
|
cmd := NewCmdProfile(nil)
|
||||||
|
if !strings.Contains(cmd.Long, "Profile selection:") {
|
||||||
|
t.Errorf("profile --help missing Profile selection section")
|
||||||
|
}
|
||||||
|
if !strings.Contains(cmd.Long, "LARKSUITE_CLI_PROFILE") {
|
||||||
|
t.Errorf("profile --help missing LARKSUITE_CLI_PROFILE")
|
||||||
|
}
|
||||||
|
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
|
||||||
|
t.Errorf("profile --help missing whoami identity route")
|
||||||
|
}
|
||||||
|
if !strings.Contains(cmd.Long, "config show / profile list") {
|
||||||
|
t.Errorf("profile --help missing saved-config boundary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileListHelpClarifiesSavedProfiles(t *testing.T) {
|
||||||
|
cmd := NewCmdProfileList(nil)
|
||||||
|
if !strings.Contains(cmd.Short, "saved profiles") {
|
||||||
|
t.Errorf("profile list short = %q, want saved profiles", cmd.Short)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
|
||||||
|
t.Errorf("profile list help missing whoami route")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
|
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
|
||||||
dir := setupProfileConfigDir(t)
|
dir := setupProfileConfigDir(t)
|
||||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
|
||||||
|
|||||||
127
cmd/root.go
127
cmd/root.go
@@ -11,9 +11,11 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/cmd/service"
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/extension/platform"
|
"github.com/larksuite/cli/extension/platform"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
|
"github.com/larksuite/cli/internal/cmdmeta"
|
||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/deprecation"
|
"github.com/larksuite/cli/internal/deprecation"
|
||||||
@@ -28,43 +30,60 @@ import (
|
|||||||
|
|
||||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||||
|
|
||||||
USAGE:
|
AGENT QUICKSTART (driving this as an agent? start here):
|
||||||
lark-cli <command> [subcommand] [method] [options]
|
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
|
||||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
|
||||||
lark-cli schema <service.resource.method>
|
Prefer a +shortcut over the raw API resource when one matches the task.
|
||||||
|
Risk: each command's --help shows read | write | high-risk-write;
|
||||||
|
high-risk-write needs --yes, only after the user confirms.
|
||||||
|
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
|
||||||
|
|
||||||
EXAMPLES:
|
EXAMPLES (one per command style, in order of preference):
|
||||||
# View upcoming events
|
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
|
||||||
lark-cli calendar +agenda
|
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
|
||||||
|
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
|
||||||
|
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
|
||||||
|
|
||||||
# List calendar events
|
// rootUsageTemplate is cobra's default usage template with two root-only
|
||||||
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
|
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
|
||||||
|
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
|
||||||
|
// footer. Subcommands render the stock template unchanged. The rest is verbatim
|
||||||
|
// cobra so the command groups and flags are untouched.
|
||||||
|
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
|
||||||
|
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||||
|
{{.CommandPath}} [command]{{end}}{{else}}Usage:
|
||||||
|
lark-cli <command> [subcommand] [method] [flags]
|
||||||
|
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||||
|
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
|
||||||
|
|
||||||
# Search users
|
Aliases:
|
||||||
lark-cli contact +search-user --query "John"
|
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||||
|
|
||||||
# Generic API call
|
Examples:
|
||||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
||||||
|
|
||||||
AI AGENT SKILLS:
|
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
|
||||||
teach the agent Lark API patterns, best practices, and workflows.
|
|
||||||
|
|
||||||
Install all skills:
|
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||||
npx skills add larksuite/cli -g -y
|
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||||
|
|
||||||
Or pick specific domains:
|
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
||||||
npx skills add larksuite/cli -s lark-calendar -y
|
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||||
npx skills add larksuite/cli -s lark-im -y
|
|
||||||
|
|
||||||
Learn more: https://github.com/larksuite/cli#agent-skills
|
Flags:
|
||||||
|
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||||
|
|
||||||
COMMUNITY:
|
Global Flags:
|
||||||
GitHub: https://github.com/larksuite/cli
|
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||||
Issues: https://github.com/larksuite/cli/issues
|
|
||||||
Docs: https://open.feishu.cn/document/
|
|
||||||
|
|
||||||
More help: lark-cli <command> --help`
|
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||||
|
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||||
|
|
||||||
|
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
|
||||||
|
|
||||||
|
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
|
||||||
|
`
|
||||||
|
|
||||||
// Execute runs the root command and returns the process exit code.
|
// Execute runs the root command and returns the process exit code.
|
||||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||||
@@ -529,6 +548,49 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
|
|||||||
return available, deprecated
|
return available, deprecated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Root command help groups, so an agent sees content domains, agent tooling, and
|
||||||
|
// CLI management as distinct blocks instead of one flat alphabetical dump.
|
||||||
|
const (
|
||||||
|
groupDomains = "lark-domains"
|
||||||
|
groupTooling = "agent-tooling"
|
||||||
|
groupManagement = "cli-management"
|
||||||
|
)
|
||||||
|
|
||||||
|
// groupRootCommands classifies root's direct children into the help groups,
|
||||||
|
// called once after all commands are registered. Unclassified commands fall to
|
||||||
|
// cobra's "Additional Commands" section.
|
||||||
|
func groupRootCommands(root *cobra.Command) {
|
||||||
|
root.AddGroup(
|
||||||
|
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
|
||||||
|
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
|
||||||
|
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
|
||||||
|
)
|
||||||
|
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
|
||||||
|
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
|
||||||
|
for _, c := range root.Commands() {
|
||||||
|
if c.GroupID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case tooling[c.Name()]:
|
||||||
|
c.GroupID = groupTooling
|
||||||
|
case management[c.Name()]:
|
||||||
|
c.GroupID = groupManagement
|
||||||
|
case isLarkDomain(c):
|
||||||
|
c.GroupID = groupDomains
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
|
||||||
|
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
|
||||||
|
func isLarkDomain(c *cobra.Command) bool {
|
||||||
|
if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return cmdmeta.Domain(c) != ""
|
||||||
|
}
|
||||||
|
|
||||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||||
// converts cobra's flag-parse errors into a typed validation envelope: an
|
// converts cobra's flag-parse errors into a typed validation envelope: an
|
||||||
// unknown flag gets a focused "did you mean" hint (so agents recover even when
|
// unknown flag gets a focused "did you mean" hint (so agents recover even when
|
||||||
@@ -610,6 +672,17 @@ func installTipsHelpFunc(root *cobra.Command) {
|
|||||||
defer func() { f.Hidden = true }()
|
defer func() { f.Hidden = true }()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Domain and method commands compose their agent guidance into Long lazily
|
||||||
|
// here (shortcuts attach after service registration); both skip the generic
|
||||||
|
// bottom-of-help append below.
|
||||||
|
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
|
||||||
|
defaultHelp(cmd, args)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if service.PrepareMethodHelp(cmd) {
|
||||||
|
defaultHelp(cmd, args)
|
||||||
|
return
|
||||||
|
}
|
||||||
defaultHelp(cmd, args)
|
defaultHelp(cmd, args)
|
||||||
out := cmd.OutOrStdout()
|
out := cmd.OutOrStdout()
|
||||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||||
|
|||||||
@@ -76,11 +76,13 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
// The human skills-install guidance now lives in the root usage-template
|
||||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
// footer (below the command list), not in the agent-facing Long.
|
||||||
|
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
|
||||||
|
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
|
||||||
}
|
}
|
||||||
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
90
cmd/root_upgrade.go
Normal file
90
cmd/root_upgrade.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/build"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/update"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runRootUpgrade locates the registered `update` subcommand and runs it, so the
|
||||||
|
// interactive root-command upgrade reuses exactly `lark-cli update` behavior
|
||||||
|
// (install-method detection, output, error handling). Package-level var so
|
||||||
|
// tests can stub it and avoid real network / self-update.
|
||||||
|
var runRootUpgrade = func(cmd *cobra.Command) {
|
||||||
|
for _, c := range cmd.Root().Commands() {
|
||||||
|
if c.Name() == "update" && c.RunE != nil {
|
||||||
|
_ = c.RunE(c, nil) // update prints its own output/errors; swallow here
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
|
||||||
|
// no flags) — the only invocation that triggers the interactive upgrade prompt.
|
||||||
|
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
|
||||||
|
// AND no flag tokens in the raw invocation.
|
||||||
|
func isBareRootInvocation(args []string) bool {
|
||||||
|
return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// readYes reads one line and reports whether it is an affirmative y/yes.
|
||||||
|
// EOF / empty / anything else → false (default No, matching the [y/N] prompt).
|
||||||
|
func readYes(r io.Reader) bool {
|
||||||
|
line, _ := bufio.NewReader(r).ReadString('\n')
|
||||||
|
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||||
|
case "y", "yes":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerRootUpgrade prompts for an interactive upgrade when running bare
|
||||||
|
// `lark-cli` in an interactive terminal with a cached newer version. Every
|
||||||
|
// failure is swallowed — it must never affect help output or the exit code.
|
||||||
|
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||||
|
ios := f.IOStreams
|
||||||
|
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
|
||||||
|
// stdout TTY too so this only fires in a pure foreground terminal session.
|
||||||
|
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
|
||||||
|
// and the IsNewer/semver validation chain; it reads the on-disk cache that
|
||||||
|
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
|
||||||
|
info := update.CheckCached(build.Version)
|
||||||
|
if info == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
|
||||||
|
if !readYes(ios.In) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runRootUpgrade(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// installRootUpgradePrompt wraps the root command's RunE (set to
|
||||||
|
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
|
||||||
|
// invocation offers an interactive upgrade before printing help. Non-bare
|
||||||
|
// invocations are passed straight through, unchanged.
|
||||||
|
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
|
||||||
|
inner := root.RunE
|
||||||
|
if inner == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.RunE = func(cmd *cobra.Command, args []string) error {
|
||||||
|
if isBareRootInvocation(args) {
|
||||||
|
offerRootUpgrade(f, cmd)
|
||||||
|
}
|
||||||
|
return inner(cmd, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
191
cmd/root_upgrade_test.go
Normal file
191
cmd/root_upgrade_test.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/build"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeUpdateState(t *testing.T, dir, latest string) {
|
||||||
|
t.Helper()
|
||||||
|
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadYes(t *testing.T) {
|
||||||
|
cases := map[string]bool{
|
||||||
|
"y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true,
|
||||||
|
"n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := readYes(strings.NewReader(in)); got != want {
|
||||||
|
t.Errorf("readYes(%q) = %v, want %v", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBareRootInvocation(t *testing.T) {
|
||||||
|
orig := rawInvocationArgs
|
||||||
|
t.Cleanup(func() { rawInvocationArgs = orig })
|
||||||
|
|
||||||
|
rawInvocationArgs = nil
|
||||||
|
if !isBareRootInvocation([]string{}) {
|
||||||
|
t.Error("empty args + no raw flag tokens should be bare")
|
||||||
|
}
|
||||||
|
rawInvocationArgs = []string{"--profile", "x"}
|
||||||
|
if isBareRootInvocation([]string{}) {
|
||||||
|
t.Error("flag token present → not bare")
|
||||||
|
}
|
||||||
|
rawInvocationArgs = nil
|
||||||
|
if isBareRootInvocation([]string{"im"}) {
|
||||||
|
t.Error("positional arg → not bare")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOfferRootUpgrade(t *testing.T) {
|
||||||
|
origV := build.Version
|
||||||
|
build.Version = "1.0.0" // release version so shouldSkip()==false
|
||||||
|
t.Cleanup(func() { build.Version = origV })
|
||||||
|
|
||||||
|
origRun := runRootUpgrade
|
||||||
|
t.Cleanup(func() { runRootUpgrade = origRun })
|
||||||
|
|
||||||
|
// This test builds a Factory literal (no NewDefault), so it never runs
|
||||||
|
// workspace detection; pin the process-global workspace to Local so
|
||||||
|
// statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale
|
||||||
|
// subdir inherited from a prior test in the package.
|
||||||
|
origWS := core.CurrentWorkspace()
|
||||||
|
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
|
||||||
|
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in, out, err bool
|
||||||
|
input string
|
||||||
|
latest string // "" → no state file (CheckCached nil)
|
||||||
|
optOut bool
|
||||||
|
wantPrompt, wantRun bool
|
||||||
|
}{
|
||||||
|
{"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true},
|
||||||
|
{"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true},
|
||||||
|
{"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false},
|
||||||
|
{"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false},
|
||||||
|
{"all-tty+eof", true, true, true, "", "2.0.0", false, true, false},
|
||||||
|
{"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false},
|
||||||
|
{"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false},
|
||||||
|
{"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false},
|
||||||
|
{"no-newer-version", true, true, true, "y\n", "", false, false, false},
|
||||||
|
{"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt
|
||||||
|
{"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false},
|
||||||
|
{"opt-out", true, true, true, "y\n", "2.0.0", true, false, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
// Clear env that update.shouldSkip treats as "suppress" so the
|
||||||
|
// test is deterministic regardless of host (GitHub Actions sets
|
||||||
|
// CI=true, which would otherwise suppress the prompt).
|
||||||
|
t.Setenv("CI", "")
|
||||||
|
t.Setenv("BUILD_NUMBER", "")
|
||||||
|
t.Setenv("RUN_ID", "")
|
||||||
|
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
|
||||||
|
if tc.latest != "" {
|
||||||
|
writeUpdateState(t, dir, tc.latest)
|
||||||
|
}
|
||||||
|
if tc.optOut {
|
||||||
|
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||||
|
}
|
||||||
|
called := false
|
||||||
|
runRootUpgrade = func(*cobra.Command) { called = true }
|
||||||
|
|
||||||
|
var errBuf bytes.Buffer
|
||||||
|
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||||
|
In: strings.NewReader(tc.input),
|
||||||
|
Out: &bytes.Buffer{},
|
||||||
|
ErrOut: &errBuf,
|
||||||
|
IsTerminal: tc.in,
|
||||||
|
OutIsTerminal: tc.out,
|
||||||
|
StderrIsTerminal: tc.err,
|
||||||
|
}}
|
||||||
|
offerRootUpgrade(f, &cobra.Command{})
|
||||||
|
|
||||||
|
gotPrompt := strings.Contains(errBuf.String(), "available")
|
||||||
|
if gotPrompt != tc.wantPrompt {
|
||||||
|
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
|
||||||
|
}
|
||||||
|
if called != tc.wantRun {
|
||||||
|
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
|
||||||
|
orig := rawInvocationArgs
|
||||||
|
t.Cleanup(func() { rawInvocationArgs = orig })
|
||||||
|
rawInvocationArgs = nil
|
||||||
|
|
||||||
|
innerCalls := 0
|
||||||
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
|
root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil }
|
||||||
|
|
||||||
|
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||||
|
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||||
|
}}
|
||||||
|
installRootUpgradePrompt(f, root)
|
||||||
|
|
||||||
|
if err := root.RunE(root, []string{}); err != nil {
|
||||||
|
t.Fatalf("bare RunE err = %v", err)
|
||||||
|
}
|
||||||
|
if err := root.RunE(root, []string{"im"}); err != nil {
|
||||||
|
t.Fatalf("non-bare RunE err = %v", err)
|
||||||
|
}
|
||||||
|
if innerCalls != 2 {
|
||||||
|
t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch
|
||||||
|
// path (not the stub used elsewhere): from any command it must locate the
|
||||||
|
// registered "update" subcommand via cmd.Root() and invoke its RunE.
|
||||||
|
func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) {
|
||||||
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
|
ran := 0
|
||||||
|
root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }})
|
||||||
|
child := &cobra.Command{Use: "im"}
|
||||||
|
root.AddCommand(child)
|
||||||
|
|
||||||
|
runRootUpgrade(child) // child.Root() resolves to root, which has "update"
|
||||||
|
|
||||||
|
if ran != 1 {
|
||||||
|
t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard:
|
||||||
|
// when root has no RunE, installRootUpgradePrompt must not wrap it.
|
||||||
|
func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
|
||||||
|
root := &cobra.Command{Use: "lark-cli"} // RunE is nil
|
||||||
|
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||||
|
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||||
|
}}
|
||||||
|
installRootUpgradePrompt(f, root)
|
||||||
|
if root.RunE != nil {
|
||||||
|
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,41 +4,211 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/affordance"
|
||||||
|
"github.com/larksuite/cli/internal/cmdmeta"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/meta"
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// methodLong composes a method command's long help in one place: the
|
// PrepareDomainHelp appends navigational guidance (routing line, risk legend,
|
||||||
// description, the affordance guidance block (when the method has one), the
|
// skill pointer) to a top-level Lark domain's description, returning false for
|
||||||
// pointer to the full schema, and the params-only addendum (params whose flag
|
// anything that is not such a domain. Built lazily at help time because
|
||||||
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
|
// shortcuts attach after service registration. skillFS (nil-safe) gates the
|
||||||
// sits near the top so an agent sees when-to-use and few-shot examples before
|
// skill pointer.
|
||||||
// the flag list.
|
//
|
||||||
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
|
// A hand-authored Long is preserved as the base (e.g. event's "Use 'event
|
||||||
|
// consume <EventKey>'…"); service domains carry only a Short at this point, so
|
||||||
|
// we fall back to it. The pristine base is captured once into an annotation so
|
||||||
|
// re-rendering does not append the guidance twice.
|
||||||
|
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||||
|
if cmd.Annotations[schemaPathAnnotation] != "" {
|
||||||
|
return false // a method command
|
||||||
|
}
|
||||||
|
// Direct child of root only — so Domain() reads this command's own tag, and
|
||||||
|
// nested resource groups are excluded.
|
||||||
|
if cmd.Parent() == nil || cmd.Parent().Parent() != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// A domain is service-sourced or shortcut-tagged; CLI tooling has neither.
|
||||||
|
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceService && cmdmeta.Domain(cmd) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !cmd.HasAvailableSubCommands() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
hasShortcuts, hasResources := false, false
|
||||||
|
for _, c := range cmd.Commands() {
|
||||||
|
if c.Hidden || c.Name() == "help" || c.Name() == "completion" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(c.Name(), "+") {
|
||||||
|
hasShortcuts = true
|
||||||
|
} else {
|
||||||
|
hasResources = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(domainHelpBase(cmd))
|
||||||
|
if hasShortcuts && hasResources { // routing only matters when both styles exist
|
||||||
|
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
|
||||||
|
}
|
||||||
|
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
|
||||||
|
if skill := "lark-" + cmd.Name(); skillFS != nil {
|
||||||
|
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
|
||||||
|
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.Long = b.String()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainHelpBase returns the description to seed domain help with — the
|
||||||
|
// hand-authored Long when present, else the Short — captured once into an
|
||||||
|
// annotation so re-rendering reuses the pristine text instead of the
|
||||||
|
// already-augmented Long.
|
||||||
|
func domainHelpBase(cmd *cobra.Command) string {
|
||||||
|
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
base := cmd.Long
|
||||||
|
if base == "" {
|
||||||
|
base = cmd.Short
|
||||||
|
}
|
||||||
|
if cmd.Annotations == nil {
|
||||||
|
cmd.Annotations = map[string]string{}
|
||||||
|
}
|
||||||
|
cmd.Annotations[domainBaseAnnotation] = base
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// methodLong is the build-time Long (description + schema pointer +
|
||||||
|
// params-only addendum). Agent guidance is added lazily by PrepareMethodHelp,
|
||||||
|
// so command construction never parses the overlay.
|
||||||
|
func methodLong(description, schemaPath, paramsOnly string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(description)
|
b.WriteString(description)
|
||||||
if affordance != "" {
|
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||||
b.WriteString("\n\n")
|
|
||||||
b.WriteString(affordance)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
|
|
||||||
b.WriteString(paramsOnly)
|
b.WriteString(paramsOnly)
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderAffordance renders a method's affordance as a help block — when to use,
|
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
|
||||||
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
|
const (
|
||||||
// the method carries no affordance. It reads the single typed model
|
affordanceServiceAnnotation = "affordance-service"
|
||||||
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
|
affordanceMethodAnnotation = "affordance-method"
|
||||||
|
schemaPathAnnotation = "method-schema-path"
|
||||||
|
paramsOnlyAnnotation = "method-params-only"
|
||||||
|
domainBaseAnnotation = "affordance-domain-base"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||||
|
// few strings is the only build-time cost; the overlay stays untouched).
|
||||||
|
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
|
||||||
|
if cmd.Annotations == nil {
|
||||||
|
cmd.Annotations = map[string]string{}
|
||||||
|
}
|
||||||
|
if service != "" && methodID != "" {
|
||||||
|
cmd.Annotations[affordanceServiceAnnotation] = service
|
||||||
|
cmd.Annotations[affordanceMethodAnnotation] = methodID
|
||||||
|
}
|
||||||
|
cmd.Annotations[schemaPathAnnotation] = schemaPath
|
||||||
|
if paramsOnly != "" {
|
||||||
|
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
|
||||||
|
// guidance at the TOP (Risk, then the affordance block, then the schema
|
||||||
|
// pointer), returning false for non-method commands. The overlay is parsed
|
||||||
|
// here — only when help is rendered.
|
||||||
|
func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||||
|
ann := cmd.Annotations
|
||||||
|
if ann == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
schemaPath, ok := ann[schemaPathAnnotation]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(cmd.Short)
|
||||||
|
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||||
|
// --yes asserts the USER confirmed; the agent must not self-approve.
|
||||||
|
if level == cmdutil.RiskHighRiskWrite {
|
||||||
|
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, "\n\nRisk: %s", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var skills []string
|
||||||
|
if raw, ok := affordanceRaw(cmd); ok {
|
||||||
|
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(block)
|
||||||
|
}
|
||||||
|
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
|
||||||
|
skills = a.Skills
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||||
|
b.WriteString(ann[paramsOnlyAnnotation])
|
||||||
|
|
||||||
|
if len(skills) > 0 {
|
||||||
|
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
|
||||||
|
for _, s := range skills {
|
||||||
|
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Long = b.String()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// affordanceLookup is the overlay source; a package var so tests can inject.
|
||||||
|
var affordanceLookup = affordance.For
|
||||||
|
|
||||||
|
// RenderAffordanceForCmd renders a method command's affordance block, or "" when
|
||||||
|
// it carries none.
|
||||||
|
func RenderAffordanceForCmd(cmd *cobra.Command) string {
|
||||||
|
raw, ok := affordanceRaw(cmd)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return renderAffordance(meta.Method{Affordance: raw})
|
||||||
|
}
|
||||||
|
|
||||||
|
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
|
||||||
|
if cmd.Annotations == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
service := cmd.Annotations[affordanceServiceAnnotation]
|
||||||
|
methodID := cmd.Annotations[affordanceMethodAnnotation]
|
||||||
|
if service == "" || methodID == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return affordanceLookup(service, methodID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderAffordance renders a method's affordance as a help block, or "" when it
|
||||||
|
// has none. Sections are joined with blank lines so they scan as distinct groups.
|
||||||
func renderAffordance(m meta.Method) string {
|
func renderAffordance(m meta.Method) string {
|
||||||
a, ok := m.ParsedAffordance()
|
a, ok := m.ParsedAffordance()
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var sections []string
|
||||||
bullets := func(title string, items []string) {
|
bullets := func(title string, items []string) {
|
||||||
var nonEmpty []string
|
var nonEmpty []string
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
@@ -49,15 +219,18 @@ func renderAffordance(m meta.Method) string {
|
|||||||
if len(nonEmpty) == 0 {
|
if len(nonEmpty) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "%s:\n", title)
|
var s strings.Builder
|
||||||
|
fmt.Fprintf(&s, "%s:\n", title)
|
||||||
for _, it := range nonEmpty {
|
for _, it := range nonEmpty {
|
||||||
fmt.Fprintf(&b, " • %s\n", it)
|
fmt.Fprintf(&s, " • %s\n", it)
|
||||||
}
|
}
|
||||||
|
sections = append(sections, strings.TrimRight(s.String(), "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
bullets("When to use", a.UseWhen)
|
bullets("When to use", a.UseWhen)
|
||||||
bullets("Avoid when", a.DoNotUseWhen)
|
bullets("Avoid when", a.AvoidWhen)
|
||||||
bullets("Prerequisites", a.Prerequisites)
|
bullets("Prerequisites", a.Prerequisites)
|
||||||
|
bullets("Tips", a.Tips)
|
||||||
if len(a.Examples) > 0 {
|
if len(a.Examples) > 0 {
|
||||||
var lines []string
|
var lines []string
|
||||||
for _, ex := range a.Examples {
|
for _, ex := range a.Examples {
|
||||||
@@ -71,10 +244,13 @@ func renderAffordance(m meta.Method) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(lines) > 0 {
|
if len(lines) > 0 {
|
||||||
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
|
sections = append(sections, "Examples:\n"+strings.Join(lines, "\n"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, ext := range a.Extensions {
|
||||||
|
bullets(ext.Label, ext.Items)
|
||||||
|
}
|
||||||
bullets("Related", a.Related)
|
bullets("Related", a.Related)
|
||||||
|
|
||||||
return strings.TrimRight(b.String(), "\n")
|
return strings.Join(sections, "\n\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,18 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdmeta"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/meta"
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRenderAffordance(t *testing.T) {
|
func TestRenderAffordance(t *testing.T) {
|
||||||
raw := json.RawMessage(`{
|
raw := json.RawMessage(`{
|
||||||
"use_when": ["发送文本消息"],
|
"use_when": ["发送文本消息"],
|
||||||
"do_not_use_when": ["群已解散"],
|
"avoid_when": ["群已解散"],
|
||||||
"prerequisites": ["已获取 chat_id"],
|
"prerequisites": ["已获取 chat_id"],
|
||||||
|
"tips": ["富文本用 msg_type=post"],
|
||||||
"examples": [
|
"examples": [
|
||||||
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
|
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
|
||||||
{"command":"lark-cli im messages list"},
|
{"command":"lark-cli im messages list"},
|
||||||
@@ -29,6 +32,7 @@ func TestRenderAffordance(t *testing.T) {
|
|||||||
"When to use:", "发送文本消息",
|
"When to use:", "发送文本消息",
|
||||||
"Avoid when:", "群已解散",
|
"Avoid when:", "群已解散",
|
||||||
"Prerequisites:", "已获取 chat_id",
|
"Prerequisites:", "已获取 chat_id",
|
||||||
|
"Tips:", "富文本用 msg_type=post",
|
||||||
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
|
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
|
||||||
"lark-cli im messages list", // example with no description -> bare command line
|
"lark-cli im messages list", // example with no description -> bare command line
|
||||||
"Related:", "im.messages.list",
|
"Related:", "im.messages.list",
|
||||||
@@ -48,9 +52,12 @@ func TestRenderAffordance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceMethod_AffordanceInLong(t *testing.T) {
|
// Affordance is rendered lazily (at --help time) rather than baked into the
|
||||||
|
// command's Long, so building a command never carries the affordance block —
|
||||||
|
// even for a method whose metadata happens to declare one.
|
||||||
|
func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
|
||||||
withAff := map[string]interface{}{
|
withAff := map[string]interface{}{
|
||||||
"path": "messages", "httpMethod": "POST", "description": "发送消息",
|
"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||||
"affordance": map[string]interface{}{
|
"affordance": map[string]interface{}{
|
||||||
"examples": []interface{}{
|
"examples": []interface{}{
|
||||||
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
|
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
|
||||||
@@ -59,14 +66,120 @@ func TestServiceMethod_AffordanceInLong(t *testing.T) {
|
|||||||
}
|
}
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
|
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
|
||||||
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
|
if strings.Contains(cmd.Long, "Examples:") {
|
||||||
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
|
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
|
||||||
}
|
}
|
||||||
|
// The lookup ref is recorded so the help path can resolve it later.
|
||||||
// A method with no affordance adds no guidance block.
|
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
|
||||||
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
|
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
|
||||||
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
|
}
|
||||||
if strings.Contains(cmd2.Long, "Examples:") {
|
}
|
||||||
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
|
|
||||||
|
// RenderAffordanceForCmd resolves a command's overlay through the (injectable)
|
||||||
|
// lookup and renders it; commands without a ref render nothing.
|
||||||
|
func TestRenderAffordanceForCmd(t *testing.T) {
|
||||||
|
orig := affordanceLookup
|
||||||
|
t.Cleanup(func() { affordanceLookup = orig })
|
||||||
|
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
|
||||||
|
if service != "im" || methodID != "messages.create" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return json.RawMessage(`{"use_when":["发文本消息"],"tips":["富文本用 msg_type=post"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||||
|
withRef := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
||||||
|
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withRef), "create", "messages", nil)
|
||||||
|
block := RenderAffordanceForCmd(cmd)
|
||||||
|
for _, want := range []string{"When to use:", "发文本消息", "Tips:", "富文本用 msg_type=post", "Examples:", "lark-cli im messages create ..."} {
|
||||||
|
if !strings.Contains(block, want) {
|
||||||
|
t.Errorf("RenderAffordanceForCmd missing %q in:\n%s", want, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No overlay for this method id -> empty block.
|
||||||
|
noRef := map[string]interface{}{"id": "x.list", "path": "x", "httpMethod": "GET", "description": "d"}
|
||||||
|
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(noRef), "list", "x", nil)
|
||||||
|
if got := RenderAffordanceForCmd(cmd2); got != "" {
|
||||||
|
t.Errorf("method with no overlay should render nothing, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareMethodHelp composes the guidance into Long at the top: description,
|
||||||
|
// then the affordance block, then the full-schema pointer — so an agent reads
|
||||||
|
// when-to-use/examples before the flag list.
|
||||||
|
func TestPrepareMethodHelp(t *testing.T) {
|
||||||
|
orig := affordanceLookup
|
||||||
|
t.Cleanup(func() { affordanceLookup = orig })
|
||||||
|
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||||
|
return json.RawMessage(`{"use_when":["发文本消息"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||||
|
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
||||||
|
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||||
|
|
||||||
|
if !PrepareMethodHelp(cmd) {
|
||||||
|
t.Fatal("PrepareMethodHelp returned false for a service-method command")
|
||||||
|
}
|
||||||
|
long := cmd.Long
|
||||||
|
// Description leads; affordance block sits above the schema pointer.
|
||||||
|
descAt := strings.Index(long, "发送消息")
|
||||||
|
useAt := strings.Index(long, "When to use:")
|
||||||
|
exAt := strings.Index(long, "Examples:")
|
||||||
|
schemaAt := strings.Index(long, "Full parameter schema:")
|
||||||
|
if descAt != 0 {
|
||||||
|
t.Errorf("description should lead Long, got:\n%s", long)
|
||||||
|
}
|
||||||
|
if !(descAt < useAt && useAt < exAt && exAt < schemaAt) {
|
||||||
|
t.Errorf("order should be description < affordance < schema pointer; got desc=%d use=%d ex=%d schema=%d\n%s", descAt, useAt, exAt, schemaAt, long)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-service command (no schema-path annotation) is left untouched.
|
||||||
|
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
|
||||||
|
t.Error("PrepareMethodHelp should return false for a non-service command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
||||||
|
// shape PrepareDomainHelp expects.
|
||||||
|
func domainCmd(short, long string) *cobra.Command {
|
||||||
|
root := &cobra.Command{Use: "root"}
|
||||||
|
dom := &cobra.Command{Use: "event", Short: short, Long: long}
|
||||||
|
cmdmeta.SetDomain(dom, "event")
|
||||||
|
dom.AddCommand(&cobra.Command{Use: "consume", Run: func(*cobra.Command, []string) {}})
|
||||||
|
root.AddCommand(dom)
|
||||||
|
return dom
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
|
||||||
|
const long = "Unified event consumption system. Use 'event consume <EventKey>'."
|
||||||
|
dom := domainCmd("Consume and manage real-time events", long)
|
||||||
|
|
||||||
|
if !PrepareDomainHelp(dom, nil) {
|
||||||
|
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(dom.Long, long) {
|
||||||
|
t.Errorf("hand-authored Long must lead; got:\n%s", dom.Long)
|
||||||
|
}
|
||||||
|
if !strings.Contains(dom.Long, "Risk levels") {
|
||||||
|
t.Errorf("domain guidance should be appended; got:\n%s", dom.Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-rendering must not append the guidance a second time.
|
||||||
|
PrepareDomainHelp(dom, nil)
|
||||||
|
if n := strings.Count(dom.Long, "Risk levels"); n != 1 {
|
||||||
|
t.Errorf("guidance appended %d times across re-renders, want 1:\n%s", n, dom.Long)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A service domain carries only a Short at help time; it seeds the base.
|
||||||
|
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
||||||
|
dom := domainCmd("Message and group chat management", "")
|
||||||
|
if !PrepareDomainHelp(dom, nil) {
|
||||||
|
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(dom.Long, "Message and group chat management") {
|
||||||
|
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,8 +60,11 @@ func TestServiceFlagGroups_AgentContract(t *testing.T) {
|
|||||||
if i := idx("--chat-id"); i < iParams || i > iBody {
|
if i := idx("--chat-id"); i < iParams || i > iBody {
|
||||||
t.Errorf("--chat-id not under API Parameters:\n%s", out)
|
t.Errorf("--chat-id not under API Parameters:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "chat_id, required") {
|
// The redundant "<name>, required|optional." prefix is gone: required-ness is
|
||||||
t.Errorf("typed flag help format wrong:\n%s", out)
|
// carried by the Required:/Optional: subheadings, and the snake-case --params
|
||||||
|
// key by the schema envelope — so it isn't echoed on every flag line.
|
||||||
|
if strings.Contains(out, "chat_id, required") || strings.Contains(out, "member_id_type, optional") {
|
||||||
|
t.Errorf("redundant <name>, required/optional prefix should not appear:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
|
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
|
||||||
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
|
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ func fieldFacts(f meta.Field) []string {
|
|||||||
if d := sanitizeFieldDesc(f.Description); d != "" {
|
if d := sanitizeFieldDesc(f.Description); d != "" {
|
||||||
facts = append(facts, d)
|
facts = append(facts, d)
|
||||||
}
|
}
|
||||||
|
if f.CanonicalType() == "boolean" {
|
||||||
|
// cobra shows no type word for bools and swallows a separate value as a
|
||||||
|
// positional, so spell out the presence-only contract.
|
||||||
|
facts = append(facts, "bool flag (presence = true; omit for false; takes no value)")
|
||||||
|
}
|
||||||
if opts := f.EnumOptions(); len(opts) > 0 {
|
if opts := f.EnumOptions(); len(opts) > 0 {
|
||||||
facts = append(facts, "enum: "+formatEnumInline(opts))
|
facts = append(facts, "enum: "+formatEnumInline(opts))
|
||||||
}
|
}
|
||||||
@@ -42,20 +47,15 @@ func fieldFacts(f meta.Field) []string {
|
|||||||
return facts
|
return facts
|
||||||
}
|
}
|
||||||
|
|
||||||
// paramFlagUsage renders the typed param flag's help line:
|
// paramFlagUsage renders the typed param flag's help line: the field's facts
|
||||||
//
|
// joined inline. Required/optional is not repeated here — the grouped help's
|
||||||
// <param_name>, required|optional[. <fact>]...
|
// Required:/Optional: subheadings already partition the flags — and the
|
||||||
//
|
// snake-case --params key is carried by the schema envelope (each param's
|
||||||
// It leads with the canonical underscore param name (the key this flag
|
// property + "flag") and the params-only addendum, so it isn't echoed on every
|
||||||
// overrides in --params) and required/optional, then joins the field's facts
|
// line either. Returns "" when the field has no facts (cobra then shows the bare
|
||||||
// inline.
|
// flag with its type).
|
||||||
func paramFlagUsage(f meta.Field) string {
|
func paramFlagUsage(f meta.Field) string {
|
||||||
req := "optional"
|
return strings.Join(fieldFacts(f), ". ")
|
||||||
if f.Required {
|
|
||||||
req = "required"
|
|
||||||
}
|
|
||||||
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
|
|
||||||
return strings.Join(parts, ". ") + "."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// paramExample picks a concrete sample for a params-only field's --help snippet:
|
// paramExample picks a concrete sample for a params-only field's --help snippet:
|
||||||
@@ -103,8 +103,23 @@ func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r",
|
|||||||
// sanitizeFieldDesc is the field-description policy: one line per field, so
|
// sanitizeFieldDesc is the field-description policy: one line per field, so
|
||||||
// keep full sentences and cut only at note separators (meta_data appends
|
// keep full sentences and cut only at note separators (meta_data appends
|
||||||
// bullet notes after ;/;) — the later sentence often carries the key
|
// bullet notes after ;/;) — the later sentence often carries the key
|
||||||
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
|
// affordance, e.g. user_mailbox_id's `可以输入"me"`. The trailing doc
|
||||||
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";;\n\r", 60) }
|
// cross-reference is dropped first (see cutDocRef).
|
||||||
|
func sanitizeFieldDesc(s string) string { return inlineClause(cutDocRef(s), ";;\n\r", 60) }
|
||||||
|
|
||||||
|
// docRefRe matches a "see the docs" breadcrumb (更多信息参见…/获取方式见…/详见…).
|
||||||
|
// On the compact flag line the markdown link's URL is stripped, so the
|
||||||
|
// breadcrumb is a dead pointer — drop it. Anchored on a leading clause separator
|
||||||
|
// so a subject that runs straight into the phrase isn't orphaned.
|
||||||
|
var docRefRe = regexp.MustCompile(`[。;;,,、]\s*(更多信息|获取方式|获取方法|详见|[请可]?参[见考阅])`)
|
||||||
|
|
||||||
|
// cutDocRef truncates s at the first doc-reference breadcrumb.
|
||||||
|
func cutDocRef(s string) string {
|
||||||
|
if loc := docRefRe.FindStringIndex(s); loc != nil {
|
||||||
|
return s[:loc[0]]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// formatEnumInline renders allowed values for the help line: "v=meaning" when
|
// formatEnumInline renders allowed values for the help line: "v=meaning" when
|
||||||
// the value carries a (sanitized, truncated) description — so opaque numeric
|
// the value carries a (sanitized, truncated) description — so opaque numeric
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
@@ -64,15 +65,38 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc
|
|||||||
// resource-command chain — one level for a flat dotted resource like
|
// resource-command chain — one level for a flat dotted resource like
|
||||||
// "chat.members", deeper for genuinely nested resources. A service with no
|
// "chat.members", deeper for genuinely nested resources. A service with no
|
||||||
// methods keeps its bare command (svcCmd is created above regardless).
|
// methods keeps its bare command (svcCmd is created above regardless).
|
||||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
refs := apicatalog.ServiceMethods(svc, nil)
|
||||||
|
|
||||||
|
// Collect each resource's verbs up front so resourceShort can summarize a
|
||||||
|
// resource as its verb list from the first ensureChildCommand call.
|
||||||
|
verbs := map[string][]string{}
|
||||||
|
for _, ref := range refs {
|
||||||
|
key := strings.Join(ref.ResourcePath, ".")
|
||||||
|
verbs[key] = append(verbs[key], ref.Method.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ref := range refs {
|
||||||
resCmd := svcCmd
|
resCmd := svcCmd
|
||||||
|
var path []string
|
||||||
for _, seg := range ref.ResourcePath {
|
for _, seg := range ref.ResourcePath {
|
||||||
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
|
path = append(path, seg)
|
||||||
|
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
|
||||||
}
|
}
|
||||||
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
|
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resourceShort summarizes a resource as its sorted verb list, or the
|
||||||
|
// "<name> operations" placeholder for an intermediate group with no methods.
|
||||||
|
func resourceShort(seg string, verbs []string) string {
|
||||||
|
if len(verbs) == 0 {
|
||||||
|
return seg + " operations"
|
||||||
|
}
|
||||||
|
sorted := append([]string(nil), verbs...)
|
||||||
|
sort.Strings(sorted)
|
||||||
|
return strings.Join(sorted, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
// serviceShort is the service command's help summary: the localized description
|
// serviceShort is the service command's help summary: the localized description
|
||||||
// from the registry, falling back to the metadata's own description.
|
// from the registry, falling back to the metadata's own description.
|
||||||
func serviceShort(svc meta.Service) string {
|
func serviceShort(svc meta.Service) string {
|
||||||
@@ -177,7 +201,19 @@ type methodCommandSpec struct {
|
|||||||
// the API declares a body.
|
// the API declares a body.
|
||||||
acceptsBody bool
|
acceptsBody bool
|
||||||
declaresBody bool
|
declaresBody bool
|
||||||
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
|
paginates bool // method accepts a page_token param (so --page-all is meaningful)
|
||||||
|
serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup
|
||||||
|
}
|
||||||
|
|
||||||
|
// methodPaginates reports whether a method takes a page_token param, the signal
|
||||||
|
// that makes the --page-all/--page-limit/--page-delay flags meaningful.
|
||||||
|
func methodPaginates(m meta.Method) bool {
|
||||||
|
for _, f := range m.Params() {
|
||||||
|
if f.Name == "page_token" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||||
@@ -186,6 +222,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
|||||||
method: m,
|
method: m,
|
||||||
schemaPath: ref.SchemaPath(),
|
schemaPath: ref.SchemaPath(),
|
||||||
servicePath: ref.Service.ServicePath,
|
servicePath: ref.Service.ServicePath,
|
||||||
|
serviceName: ref.Service.Name,
|
||||||
risk: m.Risk,
|
risk: m.Risk,
|
||||||
restricts: m.RestrictsIdentity(),
|
restricts: m.RestrictsIdentity(),
|
||||||
identities: m.Identities(),
|
identities: m.Identities(),
|
||||||
@@ -193,7 +230,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
|||||||
fileFields: detectFileFields(m),
|
fileFields: detectFileFields(m),
|
||||||
acceptsBody: methodTakesBody(m.HTTPMethod),
|
acceptsBody: methodTakesBody(m.HTTPMethod),
|
||||||
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
|
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
|
||||||
affordance: renderAffordance(m),
|
paginates: methodPaginates(m),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +291,14 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
|||||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||||
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")
|
||||||
|
// Keep the pagination flags registered (a harmless no-op if passed) but hide
|
||||||
|
// them from help on non-paginating commands, so help doesn't imply a
|
||||||
|
// get/write can paginate.
|
||||||
|
if !spec.paginates {
|
||||||
|
for _, name := range []string{"page-all", "page-limit", "page-delay"} {
|
||||||
|
_ = cmd.Flags().MarkHidden(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
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().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")
|
||||||
@@ -271,10 +316,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
|||||||
|
|
||||||
// Registered last so the collision guard sees the standard flags above.
|
// Registered last so the collision guard sees the standard flags above.
|
||||||
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
|
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
|
||||||
// Single composition point for Long: description, affordance, schema
|
// Build-time Long; the agent guidance is added lazily by PrepareMethodHelp
|
||||||
// pointer, and the binder's params-only addendum (params whose flag name is
|
// (setMethodHelpData records the coordinates it needs).
|
||||||
// taken, reachable via --params only).
|
paramsOnly := opts.binder.paramsOnlyHelp()
|
||||||
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
|
cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly)
|
||||||
|
setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly)
|
||||||
|
|
||||||
// Group flags for the grouped --help renderer (typed param flags are grouped
|
// Group flags for the grouped --help renderer (typed param flags are grouped
|
||||||
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
|
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
|
||||||
@@ -292,13 +338,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
|||||||
tagFlagGroup(cmd.Flags(), "file", groupBody)
|
tagFlagGroup(cmd.Flags(), "file", groupBody)
|
||||||
if fl := cmd.Flags().Lookup("params"); fl != nil {
|
if fl := cmd.Flags().Lookup("params"); fl != nil {
|
||||||
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
||||||
// State the precedence rule where the agent reads it: --params is the
|
// Keep the precedence rule on the flag's own one line (not a multi-line
|
||||||
// base, typed flags override. Only meaningful when typed flags exist.
|
// note that breaks the one-entry-per-flag rhythm an agent parses). Only
|
||||||
|
// meaningful when typed flags exist to override.
|
||||||
if len(spec.params) > 0 {
|
if len(spec.params) > 0 {
|
||||||
annotate(fl, flagNoteAnnotation, []string{
|
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
|
||||||
"Typed API parameter flags above are preferred.",
|
|
||||||
"If both are set, typed flags override matching keys in --params.",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
|
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
|
||||||
|
|||||||
192
cmd/whoami/whoami.go
Normal file
192
cmd/whoami/whoami.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package whoami
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/credential"
|
||||||
|
"github.com/larksuite/cli/internal/identitydiag"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
// whoamiResult is the structured output of `lark-cli whoami`.
|
||||||
|
//
|
||||||
|
// The self-vs-delegated distinction is carried by `identity`: a bot identity is
|
||||||
|
// the app acting as itself; a user identity is the app acting *on behalf of* a
|
||||||
|
// person (calls are attributed to that user, who is not necessarily present).
|
||||||
|
// onBehalfOf only *names* that person and so appears only once a user is
|
||||||
|
// resolved — a user identity that is not signed in still has identity "user"
|
||||||
|
// but no onBehalfOf yet. Do not read "no onBehalfOf" as "self"; read `identity`.
|
||||||
|
type whoamiResult struct {
|
||||||
|
Profile string `json:"profile"`
|
||||||
|
AppID string `json:"appId"`
|
||||||
|
Brand core.LarkBrand `json:"brand"`
|
||||||
|
DefaultAs string `json:"defaultAs"`
|
||||||
|
Identity string `json:"identity"`
|
||||||
|
IdentitySource string `json:"identitySource"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
TokenStatus string `json:"tokenStatus"`
|
||||||
|
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
|
||||||
|
// CredentialSource, Explicit, and DirectCredentialEnv surface the cached
|
||||||
|
// credential.IdentitySelection computed during resolution (not re-inferred
|
||||||
|
// here). CredentialSource can be empty ("") on the non-env
|
||||||
|
// extension-provider path (e.g. sidecar mode), where no selection kind
|
||||||
|
// applies; this is a documented, valid state, not an error.
|
||||||
|
CredentialSource string `json:"credentialSource"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
DirectCredentialEnv credential.DirectCredentialEnv `json:"directCredentialEnv"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// delegatedUser is the user a user-identity acts on behalf of.
|
||||||
|
type delegatedUser struct {
|
||||||
|
UserName string `json:"userName,omitempty"`
|
||||||
|
OpenID string `json:"openId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options holds inputs for the whoami command.
|
||||||
|
type Options struct {
|
||||||
|
Factory *cmdutil.Factory
|
||||||
|
As string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCmdWhoami creates the top-level whoami command. It reports the identity
|
||||||
|
// that the next API call would actually use (resolved via Factory.ResolveAs),
|
||||||
|
// together with the active profile, app, and token status. Output is always
|
||||||
|
// JSON — whoami is consumed by agents. With the built-in credential path it is
|
||||||
|
// local-only; when an external credential provider manages tokens, resolving
|
||||||
|
// the identity may contact that provider.
|
||||||
|
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
opts := &Options{Factory: f}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "whoami",
|
||||||
|
Short: "Show the current effective identity, app, profile, and token status (JSON)",
|
||||||
|
Long: `Show the effective app identity used by this invocation. This is not OAuth login status;
|
||||||
|
use ` + "`lark-cli auth status --json`" + ` for OAuth user/token state.
|
||||||
|
The JSON output includes credentialSource, appId, brand, and whether direct app credential
|
||||||
|
env is present and matches the selected profile.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return whoamiRun(cmd, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmdutil.DisableAuthCheck(cmd)
|
||||||
|
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
|
||||||
|
// Output is always JSON. Accept (and ignore) --json so existing
|
||||||
|
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
|
||||||
|
// mode exists.
|
||||||
|
cmd.Flags().Bool("json", true, "deprecated: output is always JSON")
|
||||||
|
_ = cmd.Flags().MarkHidden("json")
|
||||||
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||||
|
f := opts.Factory
|
||||||
|
cfg, err := f.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx := cmd.Context()
|
||||||
|
flagAs := core.Identity(opts.As)
|
||||||
|
as := f.ResolveAs(ctx, cmd, flagAs)
|
||||||
|
// Validate as a real API call does (strict mode, then identity) so whoami
|
||||||
|
// can't preview an identity the next call would refuse.
|
||||||
|
if err := f.CheckStrictMode(ctx, as); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
source := resolveSource(
|
||||||
|
cmd.Flags().Changed("as"),
|
||||||
|
flagAs,
|
||||||
|
f.IdentityAutoDetected,
|
||||||
|
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||||
|
)
|
||||||
|
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||||
|
// Read the cached selection computed during resolution; never re-infer it
|
||||||
|
// here. A resolution failure (e.g. under a non-env extension provider that
|
||||||
|
// doesn't populate a selection) degrades to the zero value rather than
|
||||||
|
// regressing whoami's own error/diagnostic path above.
|
||||||
|
var selection credential.IdentitySelection
|
||||||
|
if f.Credential != nil {
|
||||||
|
if sel, err := f.Credential.Selection(ctx); err == nil {
|
||||||
|
selection = sel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res := buildResult(cfg, as, source, diag, selection)
|
||||||
|
output.PrintJson(f.IOStreams.Out, res)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSource derives how the effective identity became effective.
|
||||||
|
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
|
||||||
|
// auto-detected result means auto-detect; otherwise a strict-mode forced
|
||||||
|
// identity means strict-mode; otherwise it came from configured default-as.
|
||||||
|
// Values are snake_case to match the other enum fields (e.g. tokenStatus).
|
||||||
|
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
|
||||||
|
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
|
||||||
|
return "flag"
|
||||||
|
}
|
||||||
|
if autoDetected {
|
||||||
|
return "auto_detect"
|
||||||
|
}
|
||||||
|
if strictForced != "" {
|
||||||
|
return "strict_mode"
|
||||||
|
}
|
||||||
|
return "default_as"
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildResult maps the resolved identity and local diagnostics into the output.
|
||||||
|
// ResolveAs only ever returns user or bot, so the default branch handles user.
|
||||||
|
// selection is the cached credential.IdentitySelection from resolution; it is
|
||||||
|
// read as-is, never recomputed.
|
||||||
|
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result, selection credential.IdentitySelection) *whoamiResult {
|
||||||
|
defaultAs := cfg.DefaultAs
|
||||||
|
if defaultAs == "" {
|
||||||
|
defaultAs = core.AsAuto
|
||||||
|
}
|
||||||
|
res := &whoamiResult{
|
||||||
|
Profile: cfg.ProfileName,
|
||||||
|
AppID: cfg.AppID,
|
||||||
|
Brand: cfg.Brand,
|
||||||
|
DefaultAs: string(defaultAs),
|
||||||
|
Identity: string(as),
|
||||||
|
IdentitySource: source,
|
||||||
|
CredentialSource: string(selection.Source),
|
||||||
|
Explicit: selection.Explicit(),
|
||||||
|
DirectCredentialEnv: selection.DirectCredentialEnv,
|
||||||
|
}
|
||||||
|
// Use the diagnosed hint as-is: it is tailored to the credential source, so
|
||||||
|
// it never says "auth login" when that is blocked under an external provider.
|
||||||
|
switch as {
|
||||||
|
case core.AsBot:
|
||||||
|
res.Available = diag.Bot.Available
|
||||||
|
res.TokenStatus = diag.Bot.Status
|
||||||
|
if !diag.Bot.Available {
|
||||||
|
res.Hint = diag.Bot.Hint
|
||||||
|
}
|
||||||
|
default: // user
|
||||||
|
res.Available = diag.User.Available
|
||||||
|
// Use Status (not the raw TokenStatus) so the vocab matches the bot
|
||||||
|
// branch: "ready" means usable for both. available stays the canonical
|
||||||
|
// usable signal; tokenStatus is the readable state behind it.
|
||||||
|
res.TokenStatus = diag.User.Status
|
||||||
|
// Set onBehalfOf only when a user is actually resolved; an unresolved
|
||||||
|
// user identity (not signed in) has no one to act on behalf of yet.
|
||||||
|
if diag.User.UserName != "" || diag.User.OpenID != "" {
|
||||||
|
res.OnBehalfOf = &delegatedUser{UserName: diag.User.UserName, OpenID: diag.User.OpenID}
|
||||||
|
}
|
||||||
|
if !diag.User.Available {
|
||||||
|
res.Hint = diag.User.Hint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
414
cmd/whoami/whoami_test.go
Normal file
414
cmd/whoami/whoami_test.go
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package whoami
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
extcred "github.com/larksuite/cli/extension/credential"
|
||||||
|
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/credential"
|
||||||
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
|
"github.com/larksuite/cli/internal/identitydiag"
|
||||||
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveSource(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
changedAs bool
|
||||||
|
flagAs core.Identity
|
||||||
|
autoDetected bool
|
||||||
|
strictForced core.Identity
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"explicit flag user", true, core.AsUser, false, "", "flag"},
|
||||||
|
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
|
||||||
|
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"},
|
||||||
|
{"auto detected", false, "", true, "", "auto_detect"},
|
||||||
|
{"strict mode", false, "", false, core.AsBot, "strict_mode"},
|
||||||
|
{"default_as", false, "", false, "", "default_as"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildResult_UserValid(t *testing.T) {
|
||||||
|
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
|
||||||
|
diag := identitydiag.Result{
|
||||||
|
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||||
|
}
|
||||||
|
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
|
||||||
|
|
||||||
|
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
|
||||||
|
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||||
|
}
|
||||||
|
// tokenStatus mirrors the unified Status vocab ("ready"), not the raw "valid".
|
||||||
|
if !r.Available || r.TokenStatus != "ready" {
|
||||||
|
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||||
|
}
|
||||||
|
if r.OnBehalfOf == nil || r.OnBehalfOf.OpenID != "ou_x" || r.OnBehalfOf.UserName != "Alice" {
|
||||||
|
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x", r.OnBehalfOf)
|
||||||
|
}
|
||||||
|
if r.Hint != "" {
|
||||||
|
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||||
|
}
|
||||||
|
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
|
||||||
|
t.Fatalf("app context = %#v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||||
|
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
|
||||||
|
diag := identitydiag.Result{
|
||||||
|
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
|
||||||
|
}
|
||||||
|
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
|
||||||
|
|
||||||
|
if r.Available {
|
||||||
|
t.Fatalf("available = true, want false")
|
||||||
|
}
|
||||||
|
if r.TokenStatus != "missing" {
|
||||||
|
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
|
||||||
|
}
|
||||||
|
// whoami renders the diagnosed hint verbatim (single source of truth) so it
|
||||||
|
// stays correct for the external-provider path without whoami knowing about it.
|
||||||
|
if r.Hint != diag.User.Hint {
|
||||||
|
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.User.Hint)
|
||||||
|
}
|
||||||
|
if r.DefaultAs != "auto" {
|
||||||
|
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildResult_BotReady(t *testing.T) {
|
||||||
|
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
|
||||||
|
diag := identitydiag.Result{
|
||||||
|
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
||||||
|
}
|
||||||
|
r := buildResult(cfg, core.AsBot, "default_as", diag, credential.IdentitySelection{})
|
||||||
|
|
||||||
|
if r.Identity != "bot" || r.IdentitySource != "default_as" {
|
||||||
|
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||||
|
}
|
||||||
|
if !r.Available || r.TokenStatus != "ready" {
|
||||||
|
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||||
|
}
|
||||||
|
if r.OnBehalfOf != nil {
|
||||||
|
t.Fatalf("bot must not carry onBehalfOf: %#v", r.OnBehalfOf)
|
||||||
|
}
|
||||||
|
if r.Hint != "" {
|
||||||
|
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||||
|
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
|
||||||
|
diag := identitydiag.Result{
|
||||||
|
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
|
||||||
|
}
|
||||||
|
r := buildResult(cfg, core.AsBot, "auto_detect", diag, credential.IdentitySelection{})
|
||||||
|
|
||||||
|
if r.Available {
|
||||||
|
t.Fatalf("available = true, want false")
|
||||||
|
}
|
||||||
|
if r.TokenStatus != "not_configured" {
|
||||||
|
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
|
||||||
|
}
|
||||||
|
if r.Hint != diag.Bot.Hint {
|
||||||
|
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhoami_BotJSON(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewCmdWhoami(f)
|
||||||
|
cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got whoamiResult
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if got.Identity != "bot" {
|
||||||
|
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||||
|
}
|
||||||
|
if !got.Available || got.TokenStatus != "ready" {
|
||||||
|
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
|
||||||
|
}
|
||||||
|
if got.Profile != "test-profile" {
|
||||||
|
t.Fatalf("profile = %q, want test-profile", got.Profile)
|
||||||
|
}
|
||||||
|
if got.IdentitySource == "" {
|
||||||
|
t.Fatalf("identitySource empty")
|
||||||
|
}
|
||||||
|
if got.OnBehalfOf != nil {
|
||||||
|
t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhoami_RejectsInvalidAs(t *testing.T) {
|
||||||
|
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
|
||||||
|
t.Run("as="+bad, func(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
cmd := NewCmdWhoami(f)
|
||||||
|
cmd.SetArgs([]string{"--as", bad})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
|
||||||
|
}
|
||||||
|
// Lock in the typed validation contract: an unsupported identity must
|
||||||
|
// surface as a *errs.ValidationError on --as, not just any error.
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||||
|
}
|
||||||
|
if ve.Param != "--as" {
|
||||||
|
t.Errorf("Param = %q, want %q", ve.Param, "--as")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
wantErr := fmt.Errorf("boom")
|
||||||
|
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
|
||||||
|
|
||||||
|
cmd := NewCmdWhoami(f)
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Execute() error = nil, want propagated config error")
|
||||||
|
}
|
||||||
|
// The f.Config() failure must propagate unchanged, not be masked by a later
|
||||||
|
// command-execution error.
|
||||||
|
if !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) {
|
||||||
|
// Bot-only account → strict mode bot. A real `--as user` call would be
|
||||||
|
// rejected by CheckStrictMode; whoami must reject it identically rather than
|
||||||
|
// previewing a user identity the next call would refuse.
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
SupportedIdentities: 2, // bot only
|
||||||
|
})
|
||||||
|
cmd := NewCmdWhoami(f)
|
||||||
|
cmd.SetArgs([]string{"--as", "user", "--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Execute() with --as user under strict bot = nil, want strict-mode rejection")
|
||||||
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("error type = %T, want *errs.ValidationError: %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeExtProvider struct {
|
||||||
|
name string
|
||||||
|
account *extcred.Account
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakeExtProvider) Name() string { return p.name }
|
||||||
|
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||||
|
return p.account, nil
|
||||||
|
}
|
||||||
|
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||||
|
return nil, nil // no UAT served locally; whoami runs with verify=false
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) {
|
||||||
|
cred := credential.NewCredentialProvider(
|
||||||
|
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}},
|
||||||
|
nil, nil,
|
||||||
|
func() (*http.Client, error) { return nil, nil },
|
||||||
|
)
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
f := &cmdutil.Factory{
|
||||||
|
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||||
|
Credential: cred,
|
||||||
|
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||||
|
}
|
||||||
|
return f, out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression for the external-provider blind spot: with credentials managed by
|
||||||
|
// an extension provider, a signed-in user must read as available, and an
|
||||||
|
// unavailable identity must not be told to "auth login" (which is blocked).
|
||||||
|
func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
|
||||||
|
cfg := &core.CliConfig{
|
||||||
|
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||||
|
SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice",
|
||||||
|
}
|
||||||
|
f, out := externalWhoamiFactory(cfg)
|
||||||
|
|
||||||
|
cmd := NewCmdWhoami(f)
|
||||||
|
cmd.SetArgs([]string{"--as", "user", "--json"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
var got whoamiResult
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
if got.Identity != "user" || !got.Available || got.TokenStatus != "ready" {
|
||||||
|
t.Fatalf("got %#v, want user/available/ready", got)
|
||||||
|
}
|
||||||
|
if got.OnBehalfOf == nil || got.OnBehalfOf.UserName != "Alice" || got.OnBehalfOf.OpenID != "ou_x" {
|
||||||
|
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x (delegated)", got.OnBehalfOf)
|
||||||
|
}
|
||||||
|
if got.Hint != "" {
|
||||||
|
t.Fatalf("hint = %q, want empty when available", got.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
|
||||||
|
cfg := &core.CliConfig{
|
||||||
|
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||||
|
SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in
|
||||||
|
}
|
||||||
|
f, out := externalWhoamiFactory(cfg)
|
||||||
|
|
||||||
|
cmd := NewCmdWhoami(f)
|
||||||
|
cmd.SetArgs([]string{"--as", "user", "--json"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
var got whoamiResult
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
if got.Identity != "user" || got.Available {
|
||||||
|
t.Fatalf("got identity=%q available=%v, want user/false", got.Identity, got.Available)
|
||||||
|
}
|
||||||
|
if strings.Contains(got.Hint, "auth login") {
|
||||||
|
t.Fatalf("hint must not point at auth login under external provider: %q", got.Hint)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Hint, "external") {
|
||||||
|
t.Fatalf("hint should explain external management: %q", got.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noopWhoamiKeychain is a no-op KeychainAccess; the profile below uses a
|
||||||
|
// plaintext secret, so no keychain lookup is actually required.
|
||||||
|
type noopWhoamiKeychain struct{}
|
||||||
|
|
||||||
|
func (noopWhoamiKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||||
|
func (noopWhoamiKeychain) Set(service, account, value string) error { return nil }
|
||||||
|
func (noopWhoamiKeychain) Remove(service, account string) error { return nil }
|
||||||
|
|
||||||
|
// credentialSourceSecret is the profile secret written to config for
|
||||||
|
// TestWhoamiIncludesCredentialSource. It must never leak into whoami's output
|
||||||
|
// (security §5.1).
|
||||||
|
const credentialSourceSecret = "test-secret"
|
||||||
|
|
||||||
|
// profileSelectionFactory builds a Factory whose CredentialProvider resolves
|
||||||
|
// an explicit profile ("tenant_a") supplied via the LARKSUITE_CLI_PROFILE env
|
||||||
|
// fallback (not --profile), so Selection().Source resolves to
|
||||||
|
// env:LARKSUITE_CLI_PROFILE and Explicit() is true, with no direct
|
||||||
|
// app-credential env vars present.
|
||||||
|
func profileSelectionFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv(envvars.CliAppID, "")
|
||||||
|
t.Setenv(envvars.CliAppSecret, "")
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
multi := &core.MultiAppConfig{
|
||||||
|
CurrentApp: "tenant_a",
|
||||||
|
Apps: []core.AppConfig{{
|
||||||
|
Name: "tenant_a",
|
||||||
|
AppId: "cli_a",
|
||||||
|
AppSecret: core.PlainSecret(credentialSourceSecret),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return noopWhoamiKeychain{} }, "tenant_a")
|
||||||
|
cred := credential.NewCredentialProvider([]extcred.Provider{&envprovider.Provider{}}, defaultAcct, nil, nil)
|
||||||
|
cred.WithProfile("tenant_a", false) // fromFlag=false -> env:LARKSUITE_CLI_PROFILE
|
||||||
|
|
||||||
|
cfg := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: credentialSourceSecret, Brand: core.BrandFeishu}
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
f := &cmdutil.Factory{
|
||||||
|
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||||
|
Credential: cred,
|
||||||
|
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||||
|
}
|
||||||
|
return f, out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhoamiIncludesCredentialSource locks in the diagnostic fields surfaced
|
||||||
|
// from the cached credential.IdentitySelection (Task 6): credentialSource,
|
||||||
|
// explicit, and directCredentialEnv. whoami must read the cached selection
|
||||||
|
// as-is, not re-infer it.
|
||||||
|
func TestWhoamiIncludesCredentialSource(t *testing.T) {
|
||||||
|
f, out := profileSelectionFactory(t)
|
||||||
|
|
||||||
|
cmd := NewCmdWhoami(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := out.String()
|
||||||
|
if strings.Contains(raw, credentialSourceSecret) {
|
||||||
|
t.Fatalf("whoami output leaked the profile secret: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got whoamiResult
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if got.CredentialSource != string(credential.SourceEnvProfile) {
|
||||||
|
t.Fatalf("credentialSource = %q, want %q", got.CredentialSource, credential.SourceEnvProfile)
|
||||||
|
}
|
||||||
|
if !got.Explicit {
|
||||||
|
t.Fatalf("explicit = false, want true")
|
||||||
|
}
|
||||||
|
if got.DirectCredentialEnv.Present {
|
||||||
|
t.Fatalf("directCredentialEnv.present = true, want false: %#v", got.DirectCredentialEnv)
|
||||||
|
}
|
||||||
|
if !strings.Contains(raw, `"credentialSource": "env:LARKSUITE_CLI_PROFILE"`) {
|
||||||
|
t.Fatalf("raw JSON missing credentialSource literal: %s", raw)
|
||||||
|
}
|
||||||
|
if got.DirectCredentialEnv.Present || len(got.DirectCredentialEnv.Keys) != 0 ||
|
||||||
|
got.DirectCredentialEnv.AppID != "" || got.DirectCredentialEnv.Matched || got.DirectCredentialEnv.ConflictsWithProfile {
|
||||||
|
t.Fatalf("directCredentialEnv = %#v, want only present:false set", got.DirectCredentialEnv)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
content_embed.go
Normal file
41
content_embed.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/cmd"
|
||||||
|
"github.com/larksuite/cli/internal/affordance"
|
||||||
|
)
|
||||||
|
|
||||||
|
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
|
||||||
|
// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's
|
||||||
|
// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md).
|
||||||
|
// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist —
|
||||||
|
// a new content type is omitted until added to the embed list. The embed must live
|
||||||
|
// in this root package because go:embed cannot reach up out of a package's dir.
|
||||||
|
//
|
||||||
|
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md
|
||||||
|
var embeddedContentFS embed.FS
|
||||||
|
|
||||||
|
// init wires the embedded content into the CLI. It compiles into `go build .` but
|
||||||
|
// not the single-file preview build (`go build ./main.go`), so that build stays
|
||||||
|
// self-contained (shipping no embedded content). Assembly failures warn on stderr
|
||||||
|
// rather than panicking — embedded content is nice-to-have, not load-bearing.
|
||||||
|
func init() {
|
||||||
|
if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
|
||||||
|
} else {
|
||||||
|
cmd.SetEmbeddedSkillContent(sub)
|
||||||
|
}
|
||||||
|
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
|
||||||
|
} else {
|
||||||
|
affordance.SetSource(sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,6 +136,77 @@ func TestConfigError_MarshalJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigError_ProfileFieldsMarshalJSON(t *testing.T) {
|
||||||
|
ce := NewConfigError(SubtypeAppCredentialIncomplete, "incomplete").
|
||||||
|
WithMissingKeys("LARKSUITE_CLI_APP_ID", "LARKSUITE_CLI_APP_SECRET").
|
||||||
|
WithProfile("work").
|
||||||
|
WithAppID("cli_abc").
|
||||||
|
WithCredentialSource("flag:--profile")
|
||||||
|
b, err := json.Marshal(ce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(b)
|
||||||
|
for _, want := range []string{
|
||||||
|
`"type":"config"`,
|
||||||
|
`"subtype":"app_credential_incomplete"`,
|
||||||
|
`"missing_keys":["LARKSUITE_CLI_APP_ID","LARKSUITE_CLI_APP_SECRET"]`,
|
||||||
|
`"profile":"work"`,
|
||||||
|
`"app_id":"cli_abc"`,
|
||||||
|
`"credential_source":"flag:--profile"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(s, want) {
|
||||||
|
t.Errorf("missing %q in %s", want, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// omitempty: unset fields must not appear on the wire.
|
||||||
|
empty := NewConfigError(SubtypeProfileNotFound, "x")
|
||||||
|
b2, err := json.Marshal(empty)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s2 := string(b2)
|
||||||
|
for _, notWant := range []string{`"missing_keys"`, `"profile"`, `"app_id"`, `"credential_source"`} {
|
||||||
|
if strings.Contains(s2, notWant) {
|
||||||
|
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationError_ProfileConflictMarshalJSON(t *testing.T) {
|
||||||
|
ve := NewValidationError(SubtypeProfileAppCredentialConflict, "conflict").
|
||||||
|
WithProfileAppConflict("cli_profile", "cli_env")
|
||||||
|
b, err := json.Marshal(ve)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(b)
|
||||||
|
for _, want := range []string{
|
||||||
|
`"type":"validation"`,
|
||||||
|
`"subtype":"profile_app_credential_conflict"`,
|
||||||
|
`"profile_app_id":"cli_profile"`,
|
||||||
|
`"env_app_id":"cli_env"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(s, want) {
|
||||||
|
t.Errorf("missing %q in %s", want, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// omitempty: unset conflict fields must not appear on the wire.
|
||||||
|
empty := NewValidationError(SubtypeInvalidArgument, "x")
|
||||||
|
b2, err := json.Marshal(empty)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s2 := string(b2)
|
||||||
|
for _, notWant := range []string{`"profile_app_id"`, `"env_app_id"`} {
|
||||||
|
if strings.Contains(s2, notWant) {
|
||||||
|
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNetworkError_MarshalJSON(t *testing.T) {
|
func TestNetworkError_MarshalJSON(t *testing.T) {
|
||||||
ne := &NetworkError{
|
ne := &NetworkError{
|
||||||
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},
|
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ const (
|
|||||||
|
|
||||||
// CategoryValidation subtypes
|
// CategoryValidation subtypes
|
||||||
const (
|
const (
|
||||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||||
|
SubtypeProfileAppCredentialConflict Subtype = "profile_app_credential_conflict" // profile and direct app env both set but app_id differs
|
||||||
)
|
)
|
||||||
|
|
||||||
// CategoryAuthentication subtypes
|
// CategoryAuthentication subtypes
|
||||||
@@ -41,9 +42,13 @@ const (
|
|||||||
|
|
||||||
// CategoryConfig subtypes
|
// CategoryConfig subtypes
|
||||||
const (
|
const (
|
||||||
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
|
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
|
||||||
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
|
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
|
||||||
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
|
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
|
||||||
|
SubtypeProfileNotFound Subtype = "profile_not_found" // --profile / LARKSUITE_CLI_PROFILE points to a nonexistent profile
|
||||||
|
SubtypeNoActiveProfile Subtype = "no_active_profile" // no active identity input and no usable default profile
|
||||||
|
SubtypeAppCredentialIncomplete Subtype = "app_credential_incomplete" // direct app env missing app_id or app_secret
|
||||||
|
SubtypeProfileSecretInvalid Subtype = "profile_secret_invalid" // profile exists but its secret cannot be resolved locally
|
||||||
)
|
)
|
||||||
|
|
||||||
// CategoryNetwork subtypes
|
// CategoryNetwork subtypes
|
||||||
|
|||||||
@@ -61,9 +61,11 @@ type TypedError interface {
|
|||||||
// it is intentionally not serialized.
|
// it is intentionally not serialized.
|
||||||
type ValidationError struct {
|
type ValidationError struct {
|
||||||
Problem
|
Problem
|
||||||
Param string `json:"param,omitempty"`
|
Param string `json:"param,omitempty"`
|
||||||
Params []InvalidParam `json:"params,omitempty"`
|
Params []InvalidParam `json:"params,omitempty"`
|
||||||
Cause error `json:"-"`
|
ProfileAppID string `json:"profile_app_id,omitempty"`
|
||||||
|
EnvAppID string `json:"env_app_id,omitempty"`
|
||||||
|
Cause error `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidParam is one structured validation diagnostic: the parameter that
|
// InvalidParam is one structured validation diagnostic: the parameter that
|
||||||
@@ -150,6 +152,12 @@ func (e *ValidationError) WithCause(cause error) *ValidationError {
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) WithProfileAppConflict(profileAppID, envAppID string) *ValidationError {
|
||||||
|
e.ProfileAppID = profileAppID
|
||||||
|
e.EnvAppID = envAppID
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
// =========================== AuthenticationError =============================
|
// =========================== AuthenticationError =============================
|
||||||
|
|
||||||
// AuthenticationError is the typed error for CategoryAuthentication.
|
// AuthenticationError is the typed error for CategoryAuthentication.
|
||||||
@@ -315,8 +323,17 @@ func (e *PermissionError) WithCause(cause error) *PermissionError {
|
|||||||
// intentionally not serialized.
|
// intentionally not serialized.
|
||||||
type ConfigError struct {
|
type ConfigError struct {
|
||||||
Problem
|
Problem
|
||||||
Field string `json:"field,omitempty"`
|
Field string `json:"field,omitempty"`
|
||||||
Cause error `json:"-"`
|
MissingKeys []string `json:"missing_keys,omitempty"`
|
||||||
|
Profile string `json:"profile,omitempty"`
|
||||||
|
AppID string `json:"app_id,omitempty"`
|
||||||
|
// CredentialSource is the machine-readable App/credential selection source
|
||||||
|
// that produced this config error (e.g. "flag:--profile",
|
||||||
|
// "env:LARKSUITE_CLI_PROFILE", "config"). It is required on
|
||||||
|
// profile_not_found and no_active_profile (spec §5) so an agent can branch
|
||||||
|
// on how the identity was (or was not) chosen. It is never a secret.
|
||||||
|
CredentialSource string `json:"credential_source,omitempty"`
|
||||||
|
Cause error `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||||
@@ -370,6 +387,29 @@ func (e *ConfigError) WithField(field string) *ConfigError {
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *ConfigError) WithMissingKeys(keys ...string) *ConfigError {
|
||||||
|
e.MissingKeys = slices.Clone(keys)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ConfigError) WithProfile(name string) *ConfigError {
|
||||||
|
e.Profile = name
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ConfigError) WithAppID(appID string) *ConfigError {
|
||||||
|
e.AppID = appID
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCredentialSource records the machine-readable credential-selection source
|
||||||
|
// on the wire (snake_case credential_source). The value is an enum string
|
||||||
|
// (e.g. "flag:--profile", "config"), never a secret.
|
||||||
|
func (e *ConfigError) WithCredentialSource(source string) *ConfigError {
|
||||||
|
e.CredentialSource = source
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
func (e *ConfigError) WithCause(cause error) *ConfigError {
|
func (e *ConfigError) WithCause(cause error) *ConfigError {
|
||||||
e.Cause = cause
|
e.Cause = cause
|
||||||
return e
|
return e
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
|
|||||||
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
||||||
WithMissingScopes("mail:user_mailbox.message:send").
|
WithMissingScopes("mail:user_mailbox.message:send").
|
||||||
WithIdentity("user").
|
WithIdentity("user").
|
||||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
|
||||||
|
|
||||||
if got.Category != errs.CategoryAuthorization {
|
if got.Category != errs.CategoryAuthorization {
|
||||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||||
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
|||||||
WithHint("run lark-cli auth login --scope calendar:event:create").
|
WithHint("run lark-cli auth login --scope calendar:event:create").
|
||||||
WithMissingScopes("calendar:event:create").
|
WithMissingScopes("calendar:event:create").
|
||||||
WithIdentity("user").
|
WithIdentity("user").
|
||||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
|
||||||
|
|
||||||
buf, err := json.Marshal(e)
|
buf, err := json.Marshal(e)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
|||||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
"hint": "run lark-cli auth login --scope calendar:event:create",
|
||||||
"log_id": "20260520-0a1b2c3d",
|
"log_id": "20260520-0a1b2c3d",
|
||||||
"identity": "user",
|
"identity": "user",
|
||||||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
|
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
|
||||||
"missing_scopes": []any{"calendar:event:create"},
|
"missing_scopes": []any{"calendar:event:create"},
|
||||||
}
|
}
|
||||||
for k, want := range wantFields {
|
for k, want := range wantFields {
|
||||||
@@ -643,3 +643,29 @@ func TestBuilderSetter_DefensiveCopy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======================= Profile selection error subtypes =======================
|
||||||
|
|
||||||
|
func TestConfigErrorProfileFields(t *testing.T) {
|
||||||
|
e := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "incomplete").
|
||||||
|
WithMissingKeys("LARKSUITE_CLI_APP_ID").
|
||||||
|
WithCredentialSource("env:LARKSUITE_CLI_PROFILE")
|
||||||
|
p, ok := errs.ProblemOf(e)
|
||||||
|
if !ok || p.Subtype != errs.SubtypeAppCredentialIncomplete {
|
||||||
|
t.Fatalf("subtype mismatch: %+v", p)
|
||||||
|
}
|
||||||
|
if len(e.MissingKeys) != 1 || e.MissingKeys[0] != "LARKSUITE_CLI_APP_ID" {
|
||||||
|
t.Errorf("missing_keys not set: %v", e.MissingKeys)
|
||||||
|
}
|
||||||
|
if e.CredentialSource != "env:LARKSUITE_CLI_PROFILE" {
|
||||||
|
t.Errorf("credential_source not set: %q", e.CredentialSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationErrorProfileConflict(t *testing.T) {
|
||||||
|
e := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict, "conflict").
|
||||||
|
WithProfileAppConflict("cli_profile", "cli_env")
|
||||||
|
if e.ProfileAppID != "cli_profile" || e.EnvAppID != "cli_env" {
|
||||||
|
t.Errorf("conflict fields not set: %q %q", e.ProfileAppID, e.EnvAppID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
132
events/im/card_action.go
Normal file
132
events/im/card_action.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package im
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
|
||||||
|
type CardActionTriggerOutput struct {
|
||||||
|
Type string `json:"type" desc:"Event type; always card.action.trigger"`
|
||||||
|
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"`
|
||||||
|
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"`
|
||||||
|
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"`
|
||||||
|
MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"`
|
||||||
|
ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"`
|
||||||
|
Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"`
|
||||||
|
Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"`
|
||||||
|
ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"`
|
||||||
|
ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"`
|
||||||
|
ActionName string `json:"action_name,omitempty" desc:"Element name attribute"`
|
||||||
|
FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"`
|
||||||
|
InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"`
|
||||||
|
Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"`
|
||||||
|
Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"`
|
||||||
|
Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"`
|
||||||
|
Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"`
|
||||||
|
CardContent string `json:"card_content,omitempty" desc:"Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||||
|
var envelope struct {
|
||||||
|
Header struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
CreateTime string `json:"create_time"`
|
||||||
|
} `json:"header"`
|
||||||
|
Event struct {
|
||||||
|
Operator struct {
|
||||||
|
OpenID string `json:"open_id"`
|
||||||
|
} `json:"operator"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Action struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Value map[string]interface{} `json:"value"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
FormValue map[string]interface{} `json:"form_value"`
|
||||||
|
InputValue string `json:"input_value"`
|
||||||
|
Option string `json:"option"`
|
||||||
|
Options []string `json:"options"`
|
||||||
|
Checked bool `json:"checked"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
} `json:"action"`
|
||||||
|
Context struct {
|
||||||
|
OpenMessageID string `json:"open_message_id"`
|
||||||
|
OpenChatID string `json:"open_chat_id"`
|
||||||
|
} `json:"context"`
|
||||||
|
} `json:"event"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||||
|
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
|
||||||
|
}
|
||||||
|
|
||||||
|
actionValue := marshalToString(envelope.Event.Action.Value)
|
||||||
|
formValue := marshalToString(envelope.Event.Action.FormValue)
|
||||||
|
options := strings.Join(envelope.Event.Action.Options, ",")
|
||||||
|
|
||||||
|
out := &CardActionTriggerOutput{
|
||||||
|
Type: envelope.Header.EventType,
|
||||||
|
EventID: envelope.Header.EventID,
|
||||||
|
Timestamp: envelope.Header.CreateTime,
|
||||||
|
OperatorID: envelope.Event.Operator.OpenID,
|
||||||
|
MessageID: envelope.Event.Context.OpenMessageID,
|
||||||
|
ChatID: envelope.Event.Context.OpenChatID,
|
||||||
|
Host: envelope.Event.Host,
|
||||||
|
Token: envelope.Event.Token,
|
||||||
|
ActionTag: envelope.Event.Action.Tag,
|
||||||
|
ActionValue: actionValue,
|
||||||
|
ActionName: envelope.Event.Action.Name,
|
||||||
|
FormValue: formValue,
|
||||||
|
InputValue: envelope.Event.Action.InputValue,
|
||||||
|
Option: envelope.Event.Action.Option,
|
||||||
|
Options: options,
|
||||||
|
Checked: envelope.Event.Action.Checked,
|
||||||
|
Timezone: envelope.Event.Action.Timezone,
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.MessageID != "" && rt != nil {
|
||||||
|
out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchCardUserDSL gets the card message content via message get API.
|
||||||
|
// Returns empty string on any failure — never blocks event consumption.
|
||||||
|
func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string {
|
||||||
|
path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content"
|
||||||
|
resp, err := rt.CallAPI(ctx, "GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
Items []struct {
|
||||||
|
Body struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"body"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return result.Data.Items[0].Body.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalToString(m map[string]interface{}) string {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(m)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
432
events/im/card_action_test.go
Normal file
432
events/im/card_action_test.go
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package im
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCardActionTriggerRegistered(t *testing.T) {
|
||||||
|
def, ok := event.Lookup("card.action.trigger")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("card.action.trigger should be registered via Keys()")
|
||||||
|
}
|
||||||
|
if def.Schema.Custom == nil {
|
||||||
|
t.Error("card.action.trigger must set Schema.Custom")
|
||||||
|
}
|
||||||
|
if def.Process == nil {
|
||||||
|
t.Error("card.action.trigger must set Process")
|
||||||
|
}
|
||||||
|
if len(def.Scopes) == 0 {
|
||||||
|
t.Error("Scopes must not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessCardAction_Button(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_btn_001",
|
||||||
|
"event_type": "card.action.trigger",
|
||||||
|
"create_time": "1776409469273"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"operator": {"open_id": "ou_operator"},
|
||||||
|
"token": "c-token-btn",
|
||||||
|
"host": "im_message",
|
||||||
|
"action": {
|
||||||
|
"tag": "button",
|
||||||
|
"value": {"key": "approve"},
|
||||||
|
"name": "approve_btn",
|
||||||
|
"form_value": {},
|
||||||
|
"options": [],
|
||||||
|
"checked": false
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"open_message_id": "om_msg_001",
|
||||||
|
"open_chat_id": "oc_chat_001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
out := runCardAction(t, payload, nil)
|
||||||
|
|
||||||
|
if out.Type != "card.action.trigger" {
|
||||||
|
t.Errorf("Type = %q, want card.action.trigger", out.Type)
|
||||||
|
}
|
||||||
|
if out.EventID != "ev_btn_001" {
|
||||||
|
t.Errorf("EventID = %q", out.EventID)
|
||||||
|
}
|
||||||
|
if out.OperatorID != "ou_operator" {
|
||||||
|
t.Errorf("OperatorID = %q", out.OperatorID)
|
||||||
|
}
|
||||||
|
if out.ActionTag != "button" {
|
||||||
|
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||||
|
}
|
||||||
|
if out.ActionValue != `{"key":"approve"}` {
|
||||||
|
t.Errorf("ActionValue = %q", out.ActionValue)
|
||||||
|
}
|
||||||
|
if out.ActionName != "approve_btn" {
|
||||||
|
t.Errorf("ActionName = %q", out.ActionName)
|
||||||
|
}
|
||||||
|
if out.Token != "c-token-btn" {
|
||||||
|
t.Errorf("Token = %q", out.Token)
|
||||||
|
}
|
||||||
|
if out.MessageID != "om_msg_001" {
|
||||||
|
t.Errorf("MessageID = %q", out.MessageID)
|
||||||
|
}
|
||||||
|
if out.ChatID != "oc_chat_001" {
|
||||||
|
t.Errorf("ChatID = %q", out.ChatID)
|
||||||
|
}
|
||||||
|
if out.Host != "im_message" {
|
||||||
|
t.Errorf("Host = %q", out.Host)
|
||||||
|
}
|
||||||
|
if out.Timestamp != "1776409469273" {
|
||||||
|
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessCardAction_FormSubmit(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_form_001",
|
||||||
|
"event_type": "card.action.trigger",
|
||||||
|
"create_time": "1776409469274"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"operator": {"open_id": "ou_form_user"},
|
||||||
|
"token": "c-token-form",
|
||||||
|
"host": "im_message",
|
||||||
|
"action": {
|
||||||
|
"tag": "button",
|
||||||
|
"value": {},
|
||||||
|
"name": "submit_btn",
|
||||||
|
"form_value": {"name": "test-user", "reason": "testing"},
|
||||||
|
"options": [],
|
||||||
|
"checked": false
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"open_message_id": "om_form_001",
|
||||||
|
"open_chat_id": "oc_chat_002"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
out := runCardAction(t, payload, nil)
|
||||||
|
|
||||||
|
if out.FormValue != `{"name":"test-user","reason":"testing"}` {
|
||||||
|
t.Errorf("FormValue = %q", out.FormValue)
|
||||||
|
}
|
||||||
|
if out.ActionTag != "button" {
|
||||||
|
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessCardAction_MultiSelect(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_ms_001",
|
||||||
|
"event_type": "card.action.trigger",
|
||||||
|
"create_time": "1776409469275"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"operator": {"open_id": "ou_ms_user"},
|
||||||
|
"token": "c-token-ms",
|
||||||
|
"host": "im_message",
|
||||||
|
"action": {
|
||||||
|
"tag": "multi_select_static",
|
||||||
|
"value": {},
|
||||||
|
"name": "multi_select",
|
||||||
|
"options": ["opt_1", "opt_3"],
|
||||||
|
"checked": false
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"open_message_id": "om_ms_001",
|
||||||
|
"open_chat_id": "oc_chat_003"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
out := runCardAction(t, payload, nil)
|
||||||
|
|
||||||
|
if out.Options != "opt_1,opt_3" {
|
||||||
|
t.Errorf("Options = %q, want opt_1,opt_3", out.Options)
|
||||||
|
}
|
||||||
|
if out.ActionTag != "multi_select_static" {
|
||||||
|
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessCardAction_Input(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_input_001",
|
||||||
|
"event_type": "card.action.trigger",
|
||||||
|
"create_time": "1776409469276"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"operator": {"open_id": "ou_input_user"},
|
||||||
|
"token": "c-token-input",
|
||||||
|
"host": "im_message",
|
||||||
|
"action": {
|
||||||
|
"tag": "input",
|
||||||
|
"value": {},
|
||||||
|
"name": "text_input",
|
||||||
|
"input_value": "hello world",
|
||||||
|
"options": [],
|
||||||
|
"checked": false
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"open_message_id": "om_input_001",
|
||||||
|
"open_chat_id": "oc_chat_004"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
out := runCardAction(t, payload, nil)
|
||||||
|
|
||||||
|
if out.InputValue != "hello world" {
|
||||||
|
t.Errorf("InputValue = %q", out.InputValue)
|
||||||
|
}
|
||||||
|
if out.ActionTag != "input" {
|
||||||
|
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessCardAction_DatePicker(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_date_001",
|
||||||
|
"event_type": "card.action.trigger",
|
||||||
|
"create_time": "1776409469277"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"operator": {"open_id": "ou_date_user"},
|
||||||
|
"token": "c-token-date",
|
||||||
|
"host": "im_message",
|
||||||
|
"action": {
|
||||||
|
"tag": "date_picker",
|
||||||
|
"value": {},
|
||||||
|
"name": "date_selector",
|
||||||
|
"option": "2024-04-01 +0800",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"options": [],
|
||||||
|
"checked": false
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"open_message_id": "om_date_001",
|
||||||
|
"open_chat_id": "oc_chat_005"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
out := runCardAction(t, payload, nil)
|
||||||
|
|
||||||
|
if out.Option != "2024-04-01 +0800" {
|
||||||
|
t.Errorf("Option = %q", out.Option)
|
||||||
|
}
|
||||||
|
if out.Timezone != "Asia/Shanghai" {
|
||||||
|
t.Errorf("Timezone = %q", out.Timezone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessCardAction_MalformedPayload(t *testing.T) {
|
||||||
|
raw := &event.RawEvent{
|
||||||
|
EventID: "ev_bad",
|
||||||
|
EventType: "card.action.trigger",
|
||||||
|
Payload: json.RawMessage(`not json`),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
got, err := processCardAction(context.Background(), nil, raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != "not json" {
|
||||||
|
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessCardAction_MessageGetSuccess(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_mg_ok",
|
||||||
|
"event_type": "card.action.trigger",
|
||||||
|
"create_time": "1776409469278"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"operator": {"open_id": "ou_mg_user"},
|
||||||
|
"token": "c-token-mg",
|
||||||
|
"host": "im_message",
|
||||||
|
"action": {
|
||||||
|
"tag": "button",
|
||||||
|
"value": {"key": "click"},
|
||||||
|
"name": "btn",
|
||||||
|
"form_value": {},
|
||||||
|
"options": [],
|
||||||
|
"checked": false
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"open_message_id": "om_mg_001",
|
||||||
|
"open_chat_id": "oc_chat_mg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}`
|
||||||
|
mock := &mockAPIClient{resp: `{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"items": [{
|
||||||
|
"body": {"content": "` + escapeJSON(cardContent) + `"}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
out := runCardAction(t, payload, mock)
|
||||||
|
|
||||||
|
if out.CardContent == "" {
|
||||||
|
t.Error("CardContent should not be empty when message get succeeds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessCardAction_MessageGetErrorCode(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_mg_ec",
|
||||||
|
"event_type": "card.action.trigger",
|
||||||
|
"create_time": "1776409469279"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"operator": {"open_id": "ou_mg_user2"},
|
||||||
|
"token": "c-token-mg2",
|
||||||
|
"host": "im_message",
|
||||||
|
"action": {
|
||||||
|
"tag": "button",
|
||||||
|
"value": {},
|
||||||
|
"name": "btn",
|
||||||
|
"form_value": {},
|
||||||
|
"options": [],
|
||||||
|
"checked": false
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"open_message_id": "om_mg_002",
|
||||||
|
"open_chat_id": "oc_chat_mg2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`}
|
||||||
|
out := runCardAction(t, payload, mock)
|
||||||
|
|
||||||
|
if out.CardContent != "" {
|
||||||
|
t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessCardAction_MessageGetFailure(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_mg_fail",
|
||||||
|
"event_type": "card.action.trigger",
|
||||||
|
"create_time": "1776409469280"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"operator": {"open_id": "ou_mg_user3"},
|
||||||
|
"token": "c-token-mg3",
|
||||||
|
"host": "im_message",
|
||||||
|
"action": {
|
||||||
|
"tag": "button",
|
||||||
|
"value": {},
|
||||||
|
"name": "btn",
|
||||||
|
"form_value": {},
|
||||||
|
"options": [],
|
||||||
|
"checked": false
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"open_message_id": "om_mg_003",
|
||||||
|
"open_chat_id": "oc_chat_mg3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
mock := &mockAPIClient{errResp: true}
|
||||||
|
out := runCardAction(t, payload, mock)
|
||||||
|
|
||||||
|
if out.CardContent != "" {
|
||||||
|
t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessCardAction_EmptyMessageID(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_no_msg",
|
||||||
|
"event_type": "card.action.trigger",
|
||||||
|
"create_time": "1776409469281"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"operator": {"open_id": "ou_no_msg"},
|
||||||
|
"token": "c-token-nm",
|
||||||
|
"host": "im_message",
|
||||||
|
"action": {
|
||||||
|
"tag": "button",
|
||||||
|
"value": {},
|
||||||
|
"name": "btn",
|
||||||
|
"form_value": {},
|
||||||
|
"options": [],
|
||||||
|
"checked": false
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"open_message_id": "",
|
||||||
|
"open_chat_id": "oc_chat_nm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
out := runCardAction(t, payload, nil)
|
||||||
|
|
||||||
|
if out.CardContent != "" {
|
||||||
|
t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockAPIClient struct {
|
||||||
|
resp string
|
||||||
|
errResp bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||||
|
if m.errResp {
|
||||||
|
return nil, context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
return json.RawMessage(m.resp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput {
|
||||||
|
t.Helper()
|
||||||
|
raw := &event.RawEvent{
|
||||||
|
EventID: "ev_test",
|
||||||
|
EventType: "card.action.trigger",
|
||||||
|
Payload: json.RawMessage(payload),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
got, err := processCardAction(context.Background(), rt, raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process error: %v", err)
|
||||||
|
}
|
||||||
|
var out CardActionTriggerOutput
|
||||||
|
if err := json.Unmarshal(got, &out); err != nil {
|
||||||
|
t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeJSON(s string) string {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return string(b[1 : len(b)-1])
|
||||||
|
}
|
||||||
@@ -27,6 +27,21 @@ func Keys() []event.KeyDefinition {
|
|||||||
AuthTypes: []string{"bot"},
|
AuthTypes: []string{"bot"},
|
||||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Key: "card.action.trigger",
|
||||||
|
DisplayName: "Card action",
|
||||||
|
Description: "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
|
||||||
|
EventType: "card.action.trigger",
|
||||||
|
SubscriptionType: event.SubTypeCallback,
|
||||||
|
Schema: event.SchemaDef{
|
||||||
|
Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})},
|
||||||
|
},
|
||||||
|
Process: processCardAction,
|
||||||
|
Scopes: []string{"im:message:readonly"},
|
||||||
|
AuthTypes: []string{"bot"},
|
||||||
|
SingleConsumer: true,
|
||||||
|
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rk := range nativeIMKeys {
|
for _, rk := range nativeIMKeys {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package events
|
|||||||
import (
|
import (
|
||||||
"github.com/larksuite/cli/events/im"
|
"github.com/larksuite/cli/events/im"
|
||||||
"github.com/larksuite/cli/events/minutes"
|
"github.com/larksuite/cli/events/minutes"
|
||||||
|
"github.com/larksuite/cli/events/task"
|
||||||
"github.com/larksuite/cli/events/vc"
|
"github.com/larksuite/cli/events/vc"
|
||||||
"github.com/larksuite/cli/events/whiteboard"
|
"github.com/larksuite/cli/events/whiteboard"
|
||||||
"github.com/larksuite/cli/internal/event"
|
"github.com/larksuite/cli/internal/event"
|
||||||
@@ -17,6 +18,7 @@ func init() {
|
|||||||
all := [][]event.KeyDefinition{
|
all := [][]event.KeyDefinition{
|
||||||
im.Keys(),
|
im.Keys(),
|
||||||
minutes.Keys(),
|
minutes.Keys(),
|
||||||
|
task.Keys(),
|
||||||
vc.Keys(),
|
vc.Keys(),
|
||||||
whiteboard.Keys(),
|
whiteboard.Keys(),
|
||||||
}
|
}
|
||||||
|
|||||||
23
events/task/native.go
Normal file
23
events/task/native.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package task
|
||||||
|
|
||||||
|
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
|
||||||
|
// standard Lark V2 event envelope.
|
||||||
|
type TaskUpdateUserAccessV2Data struct {
|
||||||
|
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
|
||||||
|
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskUpdateUserAccessCommitTypes = []string{
|
||||||
|
"task_create",
|
||||||
|
"task_deleted",
|
||||||
|
"task_summary_update",
|
||||||
|
"task_desc_update",
|
||||||
|
"task_assignees_update",
|
||||||
|
"task_followers_update",
|
||||||
|
"task_reminders_update",
|
||||||
|
"task_start_due_update",
|
||||||
|
"task_completed_update",
|
||||||
|
}
|
||||||
32
events/task/preconsume.go
Normal file
32
events/task/preconsume.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
|
||||||
|
|
||||||
|
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||||
|
if rt == nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||||
|
"runtime API client is required for pre-consume subscription")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
|
||||||
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, errs.NewNetworkError(
|
||||||
|
errs.SubtypeNetworkTransport,
|
||||||
|
"failed to subscribe task event",
|
||||||
|
).WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
119
events/task/preconsume_test.go
Normal file
119
events/task/preconsume_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubAPIClient struct {
|
||||||
|
err error
|
||||||
|
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body interface{}
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||||
|
s.method = method
|
||||||
|
s.path = path
|
||||||
|
s.body = body
|
||||||
|
s.calls++
|
||||||
|
if s.err != nil {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
|
||||||
|
rt := &stubAPIClient{}
|
||||||
|
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
|
||||||
|
}
|
||||||
|
if cleanup != nil {
|
||||||
|
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
|
||||||
|
}
|
||||||
|
if rt.calls != 1 {
|
||||||
|
t.Fatalf("calls = %d, want 1", rt.calls)
|
||||||
|
}
|
||||||
|
if rt.method != "POST" {
|
||||||
|
t.Errorf("method = %q, want POST", rt.method)
|
||||||
|
}
|
||||||
|
if rt.path != taskSubscriptionPath {
|
||||||
|
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
|
||||||
|
}
|
||||||
|
if rt.body != nil {
|
||||||
|
t.Errorf("body = %#v, want nil", rt.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
|
||||||
|
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryInternal {
|
||||||
|
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||||
|
}
|
||||||
|
if p.Subtype != errs.SubtypeUnknown {
|
||||||
|
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
|
||||||
|
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
|
||||||
|
rt := &stubAPIClient{err: wantErr}
|
||||||
|
|
||||||
|
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||||
|
if err != wantErr {
|
||||||
|
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, wantErr)
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryValidation {
|
||||||
|
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
|
||||||
|
}
|
||||||
|
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
|
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
|
||||||
|
cause := errors.New("connection reset")
|
||||||
|
rt := &stubAPIClient{err: cause}
|
||||||
|
|
||||||
|
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, cause) {
|
||||||
|
t.Fatalf("err = %v, want cause %v", err, cause)
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryNetwork {
|
||||||
|
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||||
|
}
|
||||||
|
if p.Subtype != errs.SubtypeNetworkTransport {
|
||||||
|
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
events/task/register.go
Normal file
33
events/task/register.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package task registers Task-domain EventKeys.
|
||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
|
||||||
|
|
||||||
|
// Keys returns all Task-domain EventKey definitions.
|
||||||
|
func Keys() []event.KeyDefinition {
|
||||||
|
return []event.KeyDefinition{
|
||||||
|
{
|
||||||
|
Key: eventTypeTaskUpdateUserAccessV2,
|
||||||
|
DisplayName: "Task updated",
|
||||||
|
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
|
||||||
|
EventType: eventTypeTaskUpdateUserAccessV2,
|
||||||
|
Schema: event.SchemaDef{
|
||||||
|
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
|
||||||
|
},
|
||||||
|
PreConsume: taskSubscriptionPreConsume,
|
||||||
|
Scopes: []string{"task:task:read"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
|
||||||
|
SingleConsumer: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
95
events/task/register_test.go
Normal file
95
events/task/register_test.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
"github.com/larksuite/cli/internal/event/schemas"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
|
||||||
|
keys := Keys()
|
||||||
|
if len(keys) != 1 {
|
||||||
|
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
def := keys[0]
|
||||||
|
if def.Key != eventTypeTaskUpdateUserAccessV2 {
|
||||||
|
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
|
||||||
|
}
|
||||||
|
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
|
||||||
|
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
|
||||||
|
}
|
||||||
|
if def.Schema.Native == nil {
|
||||||
|
t.Fatal("Schema.Native is nil")
|
||||||
|
}
|
||||||
|
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
|
||||||
|
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
|
||||||
|
}
|
||||||
|
if def.Process != nil {
|
||||||
|
t.Fatal("Native Task EventKey must not set Process")
|
||||||
|
}
|
||||||
|
if def.PreConsume == nil {
|
||||||
|
t.Fatal("PreConsume is nil")
|
||||||
|
}
|
||||||
|
if !def.SingleConsumer {
|
||||||
|
t.Fatal("SingleConsumer = false, want true")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
|
||||||
|
t.Errorf("Scopes = %#v", def.Scopes)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
|
||||||
|
t.Errorf("AuthTypes = %#v", def.AuthTypes)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
|
||||||
|
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
|
||||||
|
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
|
||||||
|
var schema map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||||
|
t.Fatalf("unmarshal schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||||
|
taskGUID := eventProps["task_guid"].(map[string]interface{})
|
||||||
|
if got := taskGUID["format"]; got != "task_guid" {
|
||||||
|
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
eventTypes := eventProps["event_types"].(map[string]interface{})
|
||||||
|
items := eventTypes["items"].(map[string]interface{})
|
||||||
|
rawEnum, ok := items["enum"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("event_types item enum missing: %#v", items["enum"])
|
||||||
|
}
|
||||||
|
got := make(map[string]bool, len(rawEnum))
|
||||||
|
for _, v := range rawEnum {
|
||||||
|
got[v.(string)] = true
|
||||||
|
}
|
||||||
|
for _, want := range taskUpdateUserAccessCommitTypes {
|
||||||
|
if !got[want] {
|
||||||
|
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
|
||||||
|
const key = eventTypeTaskUpdateUserAccessV2
|
||||||
|
event.UnregisterKeyForTest(key)
|
||||||
|
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||||
|
|
||||||
|
for _, def := range Keys() {
|
||||||
|
event.RegisterKey(def)
|
||||||
|
}
|
||||||
|
if _, ok := event.Lookup(key); !ok {
|
||||||
|
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
events/vc/participant_meeting_joined.go
Normal file
62
events/vc/participant_meeting_joined.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package vc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1.
|
||||||
|
type VCParticipantMeetingJoinedOutput struct {
|
||||||
|
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_joined_v1"`
|
||||||
|
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||||
|
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||||
|
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
||||||
|
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
||||||
|
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
||||||
|
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
||||||
|
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||||
|
var envelope struct {
|
||||||
|
Header struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
CreateTime string `json:"create_time"`
|
||||||
|
} `json:"header"`
|
||||||
|
Event struct {
|
||||||
|
Meeting struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
MeetingNo string `json:"meeting_no"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
EndTime string `json:"end_time"`
|
||||||
|
CalendarEventID string `json:"calendar_event_id"`
|
||||||
|
} `json:"meeting"`
|
||||||
|
} `json:"event"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||||
|
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||||
|
}
|
||||||
|
|
||||||
|
meeting := envelope.Event.Meeting
|
||||||
|
out := &VCParticipantMeetingJoinedOutput{
|
||||||
|
Type: envelope.Header.EventType,
|
||||||
|
EventID: envelope.Header.EventID,
|
||||||
|
Timestamp: envelope.Header.CreateTime,
|
||||||
|
MeetingID: meeting.ID,
|
||||||
|
Topic: meeting.Topic,
|
||||||
|
MeetingNo: meeting.MeetingNo,
|
||||||
|
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
||||||
|
CalendarEventID: meeting.CalendarEventID,
|
||||||
|
}
|
||||||
|
if out.Type == "" {
|
||||||
|
out.Type = raw.EventType
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
281
events/vc/participant_meeting_lifecycle_test.go
Normal file
281
events/vc/participant_meeting_lifecycle_test.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package vc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
eventType string
|
||||||
|
schemaType reflect.Type
|
||||||
|
}{
|
||||||
|
{eventTypeMeetingStarted, reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
|
||||||
|
{eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
|
||||||
|
} {
|
||||||
|
t.Run(tc.eventType, func(t *testing.T) {
|
||||||
|
def, ok := event.Lookup(tc.eventType)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||||
|
}
|
||||||
|
if def.Schema.Custom == nil {
|
||||||
|
t.Error("Processed key must set Schema.Custom")
|
||||||
|
}
|
||||||
|
if def.Schema.Native != nil {
|
||||||
|
t.Error("Processed key must not set Schema.Native")
|
||||||
|
}
|
||||||
|
if def.Process == nil {
|
||||||
|
t.Error("Process must not be nil for processed key")
|
||||||
|
}
|
||||||
|
if def.PreConsume == nil {
|
||||||
|
t.Error("PreConsume must not be nil for processed key")
|
||||||
|
}
|
||||||
|
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
|
||||||
|
t.Errorf("Scopes = %v", def.Scopes)
|
||||||
|
}
|
||||||
|
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||||
|
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||||
|
}
|
||||||
|
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
|
||||||
|
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
|
||||||
|
}
|
||||||
|
if def.Schema.Custom.Type != tc.schemaType {
|
||||||
|
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, tc.schemaType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessVCParticipantMeetingLifecycle(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
eventType string
|
||||||
|
process event.ProcessFunc
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "started",
|
||||||
|
eventType: eventTypeMeetingStarted,
|
||||||
|
process: processVCParticipantMeetingStarted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "joined",
|
||||||
|
eventType: eventTypeMeetingJoined,
|
||||||
|
process: processVCParticipantMeetingJoined,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_vc_lifecycle_001",
|
||||||
|
"event_type": "` + tc.eventType + `",
|
||||||
|
"create_time": "1608725989000",
|
||||||
|
"app_id": "cli_test"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"meeting": {
|
||||||
|
"id": "6911188411934433028",
|
||||||
|
"topic": "my meeting",
|
||||||
|
"meeting_no": "235812466",
|
||||||
|
"start_time": "1608883322",
|
||||||
|
"end_time": "1608883899",
|
||||||
|
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
out := runMeetingLifecycleMap(t, tc.eventType, tc.process, payload)
|
||||||
|
|
||||||
|
if out["type"] != tc.eventType {
|
||||||
|
t.Errorf("type = %q", out["type"])
|
||||||
|
}
|
||||||
|
if out["event_id"] != "ev_vc_lifecycle_001" {
|
||||||
|
t.Errorf("event_id = %q", out["event_id"])
|
||||||
|
}
|
||||||
|
if out["timestamp"] != "1608725989000" {
|
||||||
|
t.Errorf("timestamp = %q", out["timestamp"])
|
||||||
|
}
|
||||||
|
if out["meeting_id"] != "6911188411934433028" {
|
||||||
|
t.Errorf("meeting_id = %q", out["meeting_id"])
|
||||||
|
}
|
||||||
|
if out["topic"] != "my meeting" || out["meeting_no"] != "235812466" {
|
||||||
|
t.Errorf("topic/meeting_no = %q/%q", out["topic"], out["meeting_no"])
|
||||||
|
}
|
||||||
|
if out["calendar_event_id"] != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
|
||||||
|
t.Errorf("calendar_event_id = %q", out["calendar_event_id"])
|
||||||
|
}
|
||||||
|
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out["start_time"] != want {
|
||||||
|
t.Errorf("start_time = %q, want %q", out["start_time"], want)
|
||||||
|
}
|
||||||
|
if _, hasEndTime := out["end_time"]; hasEndTime {
|
||||||
|
t.Error("end_time should not be present in started/joined output")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessVCParticipantMeetingLifecycle_InvalidMeetingTimes(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
eventType string
|
||||||
|
process event.ProcessFunc
|
||||||
|
}{
|
||||||
|
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
|
||||||
|
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_vc_lifecycle_002",
|
||||||
|
"event_type": "` + tc.eventType + `",
|
||||||
|
"create_time": "1608725989001"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"meeting": {
|
||||||
|
"id": "meeting_invalid_time",
|
||||||
|
"start_time": "bad",
|
||||||
|
"end_time": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
out := runMeetingLifecycleRaw(t, tc.eventType, tc.process, payload)
|
||||||
|
switch tc.eventType {
|
||||||
|
case eventTypeMeetingStarted:
|
||||||
|
var started VCParticipantMeetingStartedOutput
|
||||||
|
if err := json.Unmarshal(out, &started); err != nil {
|
||||||
|
t.Fatalf("Process output is not valid started JSON: %v\nraw=%s", err, string(out))
|
||||||
|
}
|
||||||
|
if started.StartTime != "" {
|
||||||
|
t.Errorf("StartTime = %q, want empty string", started.StartTime)
|
||||||
|
}
|
||||||
|
case eventTypeMeetingJoined:
|
||||||
|
var joined VCParticipantMeetingJoinedOutput
|
||||||
|
if err := json.Unmarshal(out, &joined); err != nil {
|
||||||
|
t.Fatalf("Process output is not valid joined JSON: %v\nraw=%s", err, string(out))
|
||||||
|
}
|
||||||
|
if joined.StartTime != "" {
|
||||||
|
t.Errorf("StartTime = %q, want empty string", joined.StartTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
eventType string
|
||||||
|
process event.ProcessFunc
|
||||||
|
}{
|
||||||
|
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
|
||||||
|
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
raw := &event.RawEvent{
|
||||||
|
EventType: tc.eventType,
|
||||||
|
Payload: json.RawMessage(`not json`),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
got, err := tc.process(context.Background(), nil, raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != "not json" {
|
||||||
|
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} {
|
||||||
|
t.Run(eventType, func(t *testing.T) {
|
||||||
|
def, ok := event.Lookup(eventType)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s should be registered via Keys()", eventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
type call struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body any
|
||||||
|
}
|
||||||
|
var calls []call
|
||||||
|
rt := &stubAPIClient{
|
||||||
|
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||||
|
calls = append(calls, call{method: method, path: path, body: body})
|
||||||
|
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PreConsume error: %v", err)
|
||||||
|
}
|
||||||
|
if cleanup == nil {
|
||||||
|
t.Fatal("cleanup must not be nil")
|
||||||
|
}
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||||
|
}
|
||||||
|
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
|
||||||
|
t.Fatalf("subscribe call = %+v", calls[0])
|
||||||
|
}
|
||||||
|
assertSubscriptionRequest(t, calls[0].body, eventType)
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
if len(calls) != 2 {
|
||||||
|
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||||
|
}
|
||||||
|
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
|
||||||
|
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||||
|
}
|
||||||
|
assertSubscriptionRequest(t, calls[1].body, eventType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMeetingLifecycleMap(t *testing.T, eventType string, process event.ProcessFunc, payload string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
got := runMeetingLifecycleRaw(t, eventType, process, payload)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("Process output is nil")
|
||||||
|
}
|
||||||
|
var out map[string]string
|
||||||
|
if err := json.Unmarshal(got, &out); err != nil {
|
||||||
|
t.Fatalf("Process output is not valid flat JSON object: %v\nraw=%s", err, string(got))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMeetingLifecycleRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
raw := &event.RawEvent{
|
||||||
|
EventType: eventType,
|
||||||
|
Payload: json.RawMessage(payload),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
got, err := process(context.Background(), nil, raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process error: %v", err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
61
events/vc/participant_meeting_started.go
Normal file
61
events/vc/participant_meeting_started.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package vc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1.
|
||||||
|
type VCParticipantMeetingStartedOutput struct {
|
||||||
|
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_started_v1"`
|
||||||
|
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||||
|
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||||
|
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
||||||
|
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
||||||
|
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
||||||
|
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
||||||
|
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||||
|
var envelope struct {
|
||||||
|
Header struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
CreateTime string `json:"create_time"`
|
||||||
|
} `json:"header"`
|
||||||
|
Event struct {
|
||||||
|
Meeting struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
MeetingNo string `json:"meeting_no"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
CalendarEventID string `json:"calendar_event_id"`
|
||||||
|
} `json:"meeting"`
|
||||||
|
} `json:"event"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||||
|
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||||
|
}
|
||||||
|
|
||||||
|
meeting := envelope.Event.Meeting
|
||||||
|
out := &VCParticipantMeetingStartedOutput{
|
||||||
|
Type: envelope.Header.EventType,
|
||||||
|
EventID: envelope.Header.EventID,
|
||||||
|
Timestamp: envelope.Header.CreateTime,
|
||||||
|
MeetingID: meeting.ID,
|
||||||
|
Topic: meeting.Topic,
|
||||||
|
MeetingNo: meeting.MeetingNo,
|
||||||
|
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
||||||
|
CalendarEventID: meeting.CalendarEventID,
|
||||||
|
}
|
||||||
|
if out.Type == "" {
|
||||||
|
out.Type = raw.EventType
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
eventTypeMeetingStarted = "vc.meeting.participant_meeting_started_v1"
|
||||||
|
eventTypeMeetingJoined = "vc.meeting.participant_meeting_joined_v1"
|
||||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||||
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
|
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
|
||||||
@@ -30,6 +32,38 @@ const (
|
|||||||
// Keys returns all VC-domain EventKey definitions.
|
// Keys returns all VC-domain EventKey definitions.
|
||||||
func Keys() []event.KeyDefinition {
|
func Keys() []event.KeyDefinition {
|
||||||
return []event.KeyDefinition{
|
return []event.KeyDefinition{
|
||||||
|
{
|
||||||
|
Key: eventTypeMeetingStarted,
|
||||||
|
DisplayName: "Participant meeting started",
|
||||||
|
Description: "Triggered when a meeting the current user participates in has started",
|
||||||
|
EventType: eventTypeMeetingStarted,
|
||||||
|
Schema: event.SchemaDef{
|
||||||
|
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
|
||||||
|
},
|
||||||
|
Process: processVCParticipantMeetingStarted,
|
||||||
|
PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
||||||
|
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||||
|
AuthTypes: []string{
|
||||||
|
"user",
|
||||||
|
},
|
||||||
|
RequiredConsoleEvents: []string{eventTypeMeetingStarted},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: eventTypeMeetingJoined,
|
||||||
|
DisplayName: "Participant meeting joined",
|
||||||
|
Description: "Triggered when the current user joins a meeting",
|
||||||
|
EventType: eventTypeMeetingJoined,
|
||||||
|
Schema: event.SchemaDef{
|
||||||
|
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
|
||||||
|
},
|
||||||
|
Process: processVCParticipantMeetingJoined,
|
||||||
|
PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
||||||
|
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||||
|
AuthTypes: []string{
|
||||||
|
"user",
|
||||||
|
},
|
||||||
|
RequiredConsoleEvents: []string{eventTypeMeetingJoined},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Key: eventTypeMeetingEnded,
|
Key: eventTypeMeetingEnded,
|
||||||
DisplayName: "Participant meeting ended",
|
DisplayName: "Participant meeting ended",
|
||||||
|
|||||||
96
internal/affordance/affordance.go
Normal file
96
internal/affordance/affordance.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package affordance is the lazily-loaded store of usage guidance for
|
||||||
|
// service-API methods. The source of truth is one markdown file per service in
|
||||||
|
// the top-level affordance/ tree (see mdparse.go), injected via SetSource so
|
||||||
|
// domain owners maintain it next to skills/ and shortcuts/. A service is read
|
||||||
|
// and parsed at most once, on first access, so normal command execution never
|
||||||
|
// touches it.
|
||||||
|
package affordance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
"github.com/larksuite/cli/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
byService = map[string]map[string]json.RawMessage{}
|
||||||
|
tried = map[string]bool{}
|
||||||
|
mdSource fs.FS // top-level affordance/*.md tree; nil in the minimal preview build
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSource installs the markdown guidance tree (the top-level affordance/
|
||||||
|
// directory) as the source. Called once at startup before any lookup; clears
|
||||||
|
// the parse cache so re-sourcing (e.g. in tests) takes effect.
|
||||||
|
func SetSource(fsys fs.FS) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
mdSource = fsys
|
||||||
|
byService = map[string]map[string]json.RawMessage{}
|
||||||
|
tried = map[string]bool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For returns the raw affordance overlay for one method, loading the owning
|
||||||
|
// service on first access. ok is false when there is no entry (absent source,
|
||||||
|
// parse failure, or unknown method all collapse to "no guidance").
|
||||||
|
func For(service, methodID string) (json.RawMessage, bool) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !tried[service] {
|
||||||
|
tried[service] = true
|
||||||
|
byService[service] = loadService(service)
|
||||||
|
}
|
||||||
|
raw, ok := byService[service][methodID]
|
||||||
|
return raw, ok && len(raw) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadService parses a service's markdown guidance into per-method overlays,
|
||||||
|
// marshalling each to JSON so downstream callers keep the same wire shape.
|
||||||
|
func loadService(service string) map[string]json.RawMessage {
|
||||||
|
if mdSource == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
src, err := fs.ReadFile(mdSource, service+".md")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := map[string]json.RawMessage{}
|
||||||
|
for id, a := range parseDomainMD(src, commandFormResolver(service)) {
|
||||||
|
if b, err := json.Marshal(a); err == nil {
|
||||||
|
m[id] = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// commandFormResolver maps a method's command-form heading ("user_mailbox.messages
|
||||||
|
// list") to its method id ("user_mailbox.message.list") via the registry's
|
||||||
|
// authoritative resource↔id table. Resource names are irregularly pluralised
|
||||||
|
// (message/messages, user_mailbox/user_mailboxes), so this cannot be guessed; the
|
||||||
|
// space→dot fallback covers domains where the two already coincide.
|
||||||
|
func commandFormResolver(service string) func(string) string {
|
||||||
|
byForm := map[string]string{}
|
||||||
|
for _, svc := range registry.EmbeddedServicesTyped() {
|
||||||
|
if svc.Name != service {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||||
|
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return func(h string) string {
|
||||||
|
h = strings.TrimSpace(h)
|
||||||
|
if id, ok := byForm[h]; ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(h, " ", ".")
|
||||||
|
}
|
||||||
|
}
|
||||||
86
internal/affordance/affordance_test.go
Normal file
86
internal/affordance/affordance_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package affordance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixtureMD is a minimal affordance source: two methods, each with a lead
|
||||||
|
// paragraph (use_when) and a fenced example.
|
||||||
|
const fixtureMD = "# approval\n" +
|
||||||
|
"> skill: lark-approval\n\n" +
|
||||||
|
"## instances cc\n" +
|
||||||
|
"把一个审批实例抄送给指定用户。\n\n" +
|
||||||
|
"### Examples\n\n" +
|
||||||
|
"**抄送给用户**\n" +
|
||||||
|
"```bash\n" +
|
||||||
|
"lark-cli approval instances cc --data '{\"instance_code\":\"x\"}'\n" +
|
||||||
|
"```\n\n" +
|
||||||
|
"## instances get\n" +
|
||||||
|
"查询某审批实例详情。\n\n" +
|
||||||
|
"### Examples\n\n" +
|
||||||
|
"**按 code 查询**\n" +
|
||||||
|
"```bash\n" +
|
||||||
|
"lark-cli approval instances get --instance-code \"x\"\n" +
|
||||||
|
"```\n"
|
||||||
|
|
||||||
|
func TestFor(t *testing.T) {
|
||||||
|
prev := mdSource
|
||||||
|
t.Cleanup(func() { SetSource(prev) }) // SetSource mutates package state; restore for test isolation
|
||||||
|
SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(fixtureMD)}})
|
||||||
|
|
||||||
|
// A seeded method in a seeded service resolves to its overlay.
|
||||||
|
raw, ok := For("approval", "instances.cc")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal(`For("approval","instances.cc") ok=false, want an overlay`)
|
||||||
|
}
|
||||||
|
var a struct {
|
||||||
|
UseWhen []string `json:"use_when"`
|
||||||
|
Examples []struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
} `json:"examples"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &a); err != nil {
|
||||||
|
t.Fatalf("overlay is not valid affordance JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(a.UseWhen) == 0 || len(a.Examples) == 0 || a.Examples[0].Command == "" {
|
||||||
|
t.Errorf("overlay missing use_when/examples: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Misses: unknown method in a known service, and an unknown service, both
|
||||||
|
// resolve to ok=false (no panic, no error) so callers treat them as "no
|
||||||
|
// guidance".
|
||||||
|
if _, ok := For("approval", "instances.no_such_method"); ok {
|
||||||
|
t.Error("unknown method should be ok=false")
|
||||||
|
}
|
||||||
|
if _, ok := For("no_such_service", "x.y"); ok {
|
||||||
|
t.Error("unknown service should be ok=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second lookup of the same service is served from cache (parsed at most
|
||||||
|
// once) and stays consistent.
|
||||||
|
if _, ok := For("approval", "instances.get"); !ok {
|
||||||
|
t.Error("second lookup in a cached service should still resolve")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-bullet paragraph lines under any section are preserved as items, not
|
||||||
|
// dropped (regression: they previously only updated pending, lost without a fence).
|
||||||
|
func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
|
||||||
|
md := "# d\n\n## foo bar\nwhat it does.\n\n### Tips\n- a bullet\nplain paragraph note.\n\n### See also\nrun [[other cmd]] first.\n"
|
||||||
|
got := parseDomainMD([]byte(md), nil) // nil resolver -> space->dot, "foo bar" -> "foo.bar"
|
||||||
|
a, ok := got["foo.bar"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("method not parsed")
|
||||||
|
}
|
||||||
|
if len(a.Tips) != 2 || a.Tips[1] != "plain paragraph note." {
|
||||||
|
t.Errorf("Tips paragraph dropped: %v", a.Tips)
|
||||||
|
}
|
||||||
|
if len(a.Extensions) != 1 || len(a.Extensions[0].Items) != 1 || a.Extensions[0].Items[0] != "run `other cmd` first." {
|
||||||
|
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
180
internal/affordance/mdparse.go
Normal file
180
internal/affordance/mdparse.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package affordance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The affordance source is a narrow, fixed markdown subset (see src/*.md):
|
||||||
|
//
|
||||||
|
// # domain optional `> skill: <name>` applied to every method
|
||||||
|
// ## command e.g. `instances get`
|
||||||
|
// <lead paragraph> -> use_when (when this command is right)
|
||||||
|
// ### Avoid when -> avoid_when (links become prefer/alternative edges)
|
||||||
|
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
|
||||||
|
// ### Tips -> tips
|
||||||
|
// ### Examples -> examples: **description** + a ```fenced``` command
|
||||||
|
// ### <other> -> extensions[] (custom section, flows through verbatim)
|
||||||
|
// [[cmd]] -> a command reference, rendered as `cmd`
|
||||||
|
//
|
||||||
|
// Parsing is lazy and cached (see For), so the constrained grammar is read at
|
||||||
|
// most once per domain.
|
||||||
|
|
||||||
|
var mdLink = regexp.MustCompile(`\[\[(.+?)\]\]`)
|
||||||
|
|
||||||
|
// standardSection maps a section heading to its typed Affordance field; any
|
||||||
|
// other heading becomes an extension.
|
||||||
|
var standardSection = map[string]string{
|
||||||
|
"Avoid when": "avoid_when",
|
||||||
|
"Prerequisites": "prerequisites",
|
||||||
|
"Tips": "tips",
|
||||||
|
"Examples": "examples",
|
||||||
|
}
|
||||||
|
|
||||||
|
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
|
||||||
|
|
||||||
|
// headingToKey maps a command heading ("instances get") to its affordance key
|
||||||
|
// ("instances.get"). The space→dot rule holds where the command form matches
|
||||||
|
// the method id; domains whose resource names differ (e.g. plural "messages"
|
||||||
|
// vs id segment "message") need the registry's authoritative resource↔id table.
|
||||||
|
func headingToKey(h string) string {
|
||||||
|
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
type mdSection struct {
|
||||||
|
label string
|
||||||
|
items []string
|
||||||
|
cases []meta.AffordanceCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDomainMD parses one domain's markdown into per-method Affordance values,
|
||||||
|
// keyed by method id. resolve maps a command-form heading ("user_mailbox.messages
|
||||||
|
// list") to its method id ("user_mailbox.message.list"); nil falls back to the
|
||||||
|
// space→dot rule (valid only where the command form already equals the id).
|
||||||
|
func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affordance {
|
||||||
|
if resolve == nil {
|
||||||
|
resolve = headingToKey
|
||||||
|
}
|
||||||
|
out := map[string]meta.Affordance{}
|
||||||
|
|
||||||
|
var skill, curKey string
|
||||||
|
var useWhen, para []string // lead paragraphs -> use_when entries (blank line separates)
|
||||||
|
var secs []*mdSection
|
||||||
|
var sec *mdSection
|
||||||
|
var pending string
|
||||||
|
var fence []string
|
||||||
|
inFence := false
|
||||||
|
|
||||||
|
assemble := func() {
|
||||||
|
if curKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(para) > 0 {
|
||||||
|
useWhen = append(useWhen, strings.TrimSpace(strings.Join(para, " ")))
|
||||||
|
para = nil
|
||||||
|
}
|
||||||
|
var a meta.Affordance
|
||||||
|
if len(useWhen) > 0 {
|
||||||
|
a.UseWhen = useWhen
|
||||||
|
}
|
||||||
|
for _, s := range secs {
|
||||||
|
switch standardSection[s.label] {
|
||||||
|
case "avoid_when":
|
||||||
|
a.AvoidWhen = s.items
|
||||||
|
case "prerequisites":
|
||||||
|
a.Prerequisites = s.items
|
||||||
|
case "tips":
|
||||||
|
a.Tips = s.items
|
||||||
|
case "examples":
|
||||||
|
a.Examples = s.cases
|
||||||
|
default:
|
||||||
|
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skill != "" {
|
||||||
|
a.Skills = []string{skill}
|
||||||
|
}
|
||||||
|
out[curKey] = a
|
||||||
|
}
|
||||||
|
|
||||||
|
reset := func() { useWhen, para, secs, sec, pending, fence, inFence = nil, nil, nil, nil, "", nil, false }
|
||||||
|
|
||||||
|
// flushPending appends a non-bullet paragraph line that was not consumed as
|
||||||
|
// an example description (i.e. no fence followed) to the current section's
|
||||||
|
// items, so prose under any section is preserved rather than dropped.
|
||||||
|
flushPending := func() {
|
||||||
|
if sec != nil && pending != "" {
|
||||||
|
sec.items = append(sec.items, linkToBacktick(pending))
|
||||||
|
pending = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range strings.Split(string(src), "\n") {
|
||||||
|
line := strings.TrimRight(raw, "\r")
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "## "):
|
||||||
|
flushPending()
|
||||||
|
assemble()
|
||||||
|
curKey = resolve(line[3:])
|
||||||
|
reset()
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(line, "# "):
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(t, "> skill:"):
|
||||||
|
skill = strings.TrimSpace(t[len("> skill:"):])
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(line, "### "):
|
||||||
|
flushPending()
|
||||||
|
sec = &mdSection{label: strings.TrimSpace(line[4:])}
|
||||||
|
secs = append(secs, sec)
|
||||||
|
pending, fence, inFence = "", nil, false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if curKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sec == nil { // lead paragraphs before any section -> use_when (blank line separates entries)
|
||||||
|
if t == "" {
|
||||||
|
if len(para) > 0 {
|
||||||
|
useWhen = append(useWhen, strings.Join(para, " "))
|
||||||
|
para = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
para = append(para, t)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// inside a section: a fenced block is an example command; otherwise the
|
||||||
|
// shape follows the writing (bullet item vs **description** before a fence).
|
||||||
|
if strings.HasPrefix(t, "```") {
|
||||||
|
if !inFence {
|
||||||
|
inFence, fence = true, nil
|
||||||
|
} else {
|
||||||
|
inFence = false
|
||||||
|
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
|
||||||
|
pending = ""
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inFence {
|
||||||
|
fence = append(fence, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(t, "-") {
|
||||||
|
flushPending()
|
||||||
|
sec.items = append(sec.items, linkToBacktick(strings.TrimSpace(t[1:])))
|
||||||
|
} else if t != "" {
|
||||||
|
flushPending()
|
||||||
|
pending = strings.Trim(t, "* ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushPending()
|
||||||
|
assemble()
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -131,31 +131,3 @@ func requireInTrustedDirs(effectivePath string, trustedDirs []string, label stri
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
|
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditFilePermissions rejects world/group-writable modes (always) and
|
|
||||||
// world/group-readable modes (unless allowReadableByOthers is true, which
|
|
||||||
// exec commands typically need for their usual 755 mode).
|
|
||||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
|
||||||
info, err := vfs.Stat(effectivePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
|
||||||
}
|
|
||||||
mode := info.Mode().Perm()
|
|
||||||
|
|
||||||
if mode&0o002 != 0 {
|
|
||||||
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
|
||||||
}
|
|
||||||
if mode&0o020 != 0 {
|
|
||||||
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
|
||||||
}
|
|
||||||
if allowReadableByOthers {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if mode&0o004 != 0 {
|
|
||||||
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
|
||||||
}
|
|
||||||
if mode&0o040 != 0 {
|
|
||||||
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,3 +29,31 @@ func checkOwnerUID(path, label string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auditFilePermissions rejects world/group-writable modes (always) and
|
||||||
|
// world/group-readable modes (unless allowReadableByOthers is true, which
|
||||||
|
// exec commands typically need for their usual 755 mode).
|
||||||
|
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||||
|
info, err := vfs.Stat(effectivePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||||
|
}
|
||||||
|
mode := info.Mode().Perm()
|
||||||
|
|
||||||
|
if mode&0o002 != 0 {
|
||||||
|
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
||||||
|
}
|
||||||
|
if mode&0o020 != 0 {
|
||||||
|
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
||||||
|
}
|
||||||
|
if allowReadableByOthers {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if mode&0o004 != 0 {
|
||||||
|
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
||||||
|
}
|
||||||
|
if mode&0o040 != 0 {
|
||||||
|
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,22 @@
|
|||||||
|
|
||||||
package binding
|
package binding
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
|
)
|
||||||
|
|
||||||
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
|
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
|
||||||
func checkOwnerUID(path, label string) error {
|
func checkOwnerUID(path, label string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
|
||||||
|
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
|
||||||
|
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||||
|
if _, err := vfs.Stat(effectivePath); err != nil {
|
||||||
|
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
33
internal/binding/audit_windows_test.go
Normal file
33
internal/binding/audit_windows_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package binding
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAssertSecurePath_WindowsIgnoresSyntheticUnixPermissionBits(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "secrets-getter.cmd")
|
||||||
|
if err := os.WriteFile(p, []byte("@echo off\r\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write temp command: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := AssertSecurePath(AuditParams{
|
||||||
|
TargetPath: p,
|
||||||
|
Label: "exec provider command",
|
||||||
|
AllowInsecurePath: false,
|
||||||
|
AllowReadableByOthers: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for Windows synthetic mode bits: %v", err)
|
||||||
|
}
|
||||||
|
if got != p {
|
||||||
|
t.Errorf("got %q, want %q", got, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,11 @@ import (
|
|||||||
// In tests, replace any field to stub out external dependencies.
|
// In tests, replace any field to stub out external dependencies.
|
||||||
type InvocationContext struct {
|
type InvocationContext struct {
|
||||||
Profile string
|
Profile string
|
||||||
|
// ProfileFromFlag is true when Profile was set via the --profile flag,
|
||||||
|
// and false when it came from the LARKSUITE_CLI_PROFILE env fallback
|
||||||
|
// (or neither was set). Downstream credential resolution uses this to
|
||||||
|
// report the correct profile source.
|
||||||
|
ProfileFromFlag bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Factory struct {
|
type Factory struct {
|
||||||
|
|||||||
@@ -61,10 +61,11 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
|||||||
// Phase 2: Credential (sole data source)
|
// Phase 2: Credential (sole data source)
|
||||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
||||||
f.Credential = buildCredentialProvider(credentialDeps{
|
f.Credential = buildCredentialProvider(credentialDeps{
|
||||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||||
Profile: inv.Profile,
|
Profile: inv.Profile,
|
||||||
HttpClient: f.HttpClient,
|
ProfileFromFlag: inv.ProfileFromFlag,
|
||||||
ErrOut: f.IOStreams.ErrOut,
|
HttpClient: f.HttpClient,
|
||||||
|
ErrOut: f.IOStreams.ErrOut,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Phase 3: Config derived from Credential via an explicit conversion boundary.
|
// Phase 3: Config derived from Credential via an explicit conversion boundary.
|
||||||
@@ -162,10 +163,11 @@ func buildSDKTransport() http.RoundTripper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type credentialDeps struct {
|
type credentialDeps struct {
|
||||||
Keychain func() keychain.KeychainAccess
|
Keychain func() keychain.KeychainAccess
|
||||||
Profile string
|
Profile string
|
||||||
HttpClient func() (*http.Client, error)
|
ProfileFromFlag bool
|
||||||
ErrOut io.Writer
|
HttpClient func() (*http.Client, error)
|
||||||
|
ErrOut io.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
|
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
|
||||||
@@ -178,5 +180,6 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
|
|||||||
// depend on. enrichUserInfo failures are already non-fatal (the
|
// depend on. enrichUserInfo failures are already non-fatal (the
|
||||||
// provider clears unverified identity fields), so silencing the
|
// provider clears unverified identity fields), so silencing the
|
||||||
// warning is safe.
|
// warning is safe.
|
||||||
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
|
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient).
|
||||||
|
WithProfile(deps.Profile, deps.ProfileFromFlag)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ type IOStreams struct {
|
|||||||
Out io.Writer
|
Out io.Writer
|
||||||
ErrOut io.Writer
|
ErrOut io.Writer
|
||||||
IsTerminal bool
|
IsTerminal bool
|
||||||
|
// OutIsTerminal reports whether Out is an interactive terminal. Mirrors
|
||||||
|
// IsTerminal; computed once in NewIOStreams and assignable directly in tests.
|
||||||
|
OutIsTerminal bool
|
||||||
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
|
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
|
||||||
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
|
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
|
||||||
// so they stay out of non-interactive output (pipes, CI, agent runs).
|
// so they stay out of non-interactive output (pipes, CI, agent runs).
|
||||||
@@ -27,19 +30,24 @@ type IOStreams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
|
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
|
||||||
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
|
// IsTerminal / OutIsTerminal / StderrIsTerminal are each derived from the
|
||||||
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
|
// underlying *os.File of in / out / errOut respectively; non-file
|
||||||
// false.
|
// readers/writers (bytes.Buffer, strings.Reader, …) yield false.
|
||||||
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
|
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
|
||||||
isTerminal := false
|
fileIsTerminal := func(v any) bool {
|
||||||
if f, ok := in.(*os.File); ok {
|
if f, ok := v.(*os.File); ok {
|
||||||
isTerminal = term.IsTerminal(int(f.Fd()))
|
return term.IsTerminal(int(f.Fd()))
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
stderrIsTerminal := false
|
return &IOStreams{
|
||||||
if f, ok := errOut.(*os.File); ok {
|
In: in,
|
||||||
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
|
Out: out,
|
||||||
|
ErrOut: errOut,
|
||||||
|
IsTerminal: fileIsTerminal(in),
|
||||||
|
OutIsTerminal: fileIsTerminal(out),
|
||||||
|
StderrIsTerminal: fileIsTerminal(errOut),
|
||||||
}
|
}
|
||||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
||||||
|
|||||||
31
internal/cmdutil/iostreams_test.go
Normal file
31
internal/cmdutil/iostreams_test.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmdutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewIOStreamsTerminalFlagsNonFile(t *testing.T) {
|
||||||
|
s := NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
||||||
|
if s.IsTerminal || s.OutIsTerminal || s.StderrIsTerminal {
|
||||||
|
t.Errorf("non-file streams must not be terminals: in=%v out=%v err=%v",
|
||||||
|
s.IsTerminal, s.OutIsTerminal, s.StderrIsTerminal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewIOStreamsTerminalFlagsPipe(t *testing.T) {
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
defer w.Close()
|
||||||
|
s := NewIOStreams(r, w, w)
|
||||||
|
if s.OutIsTerminal || s.StderrIsTerminal {
|
||||||
|
t.Errorf("os.Pipe must not be a terminal: out=%v err=%v", s.OutIsTerminal, s.StderrIsTerminal)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,13 +9,21 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
extcred "github.com/larksuite/cli/extension/credential"
|
extcred "github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/internal/auth"
|
"github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// directCredentialProviderName is the Name() of the env provider, the source
|
||||||
|
// of direct app credentials (LARKSUITE_CLI_APP_ID / _APP_SECRET). Only its
|
||||||
|
// incomplete blocks map to app_credential_incomplete (spec §3 step 1).
|
||||||
|
const directCredentialProviderName = "env"
|
||||||
|
|
||||||
// DefaultAccountResolver is implemented by the default account provider.
|
// DefaultAccountResolver is implemented by the default account provider.
|
||||||
type DefaultAccountResolver interface {
|
type DefaultAccountResolver interface {
|
||||||
ResolveAccount(ctx context.Context) (*Account, error)
|
ResolveAccount(ctx context.Context) (*Account, error)
|
||||||
@@ -136,10 +144,18 @@ type CredentialProvider struct {
|
|||||||
httpClient func() (*http.Client, error)
|
httpClient func() (*http.Client, error)
|
||||||
warnOut io.Writer
|
warnOut io.Writer
|
||||||
|
|
||||||
|
// profile is the active profile (from --profile or LARKSUITE_CLI_PROFILE).
|
||||||
|
// profileFromFlag discriminates the source for the reported selection.
|
||||||
|
profile string
|
||||||
|
profileFromFlag bool
|
||||||
|
|
||||||
accountOnce sync.Once
|
accountOnce sync.Once
|
||||||
account *Account
|
account *Account
|
||||||
accountErr error
|
accountErr error
|
||||||
selectedSource credentialSource
|
selectedSource credentialSource
|
||||||
|
// selection is the explainable credential-selection result, populated by
|
||||||
|
// doResolveAccount under accountOnce. It never carries a secret (§5.1).
|
||||||
|
selection IdentitySelection
|
||||||
|
|
||||||
hintOnce sync.Once
|
hintOnce sync.Once
|
||||||
hint *IdentityHint
|
hint *IdentityHint
|
||||||
@@ -161,6 +177,15 @@ func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithProfile records the active profile and whether it came from the
|
||||||
|
// --profile flag (as opposed to the LARKSUITE_CLI_PROFILE env fallback).
|
||||||
|
// It governs credential arbitration and the reported selection source.
|
||||||
|
func (p *CredentialProvider) WithProfile(profile string, fromFlag bool) *CredentialProvider {
|
||||||
|
p.profile = profile
|
||||||
|
p.profileFromFlag = fromFlag
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveAccount resolves app credentials. Result is cached after first call.
|
// ResolveAccount resolves app credentials. Result is cached after first call.
|
||||||
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
||||||
// Subsequent calls return the cached result regardless of their context.
|
// Subsequent calls return the cached result regardless of their context.
|
||||||
@@ -172,40 +197,273 @@ func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, erro
|
|||||||
return p.account, p.accountErr
|
return p.account, p.accountErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doResolveAccount arbitrates the credential/App selection per the spec
|
||||||
|
// resolution order (§3): env-partial → profile → env-complete → config default.
|
||||||
|
// It populates p.selection (no secret; §5.1) and p.selectedSource on every
|
||||||
|
// success path.
|
||||||
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
||||||
|
// Step 1 (spec §3): consult the extension providers. The env provider is
|
||||||
|
// the "direct app credential" source. An incomplete direct credential
|
||||||
|
// (only APP_ID or only APP_SECRET set) short-circuits to
|
||||||
|
// app_credential_incomplete regardless of the active profile.
|
||||||
|
var envAcct *Account
|
||||||
|
var envSource extensionTokenSource
|
||||||
for _, prov := range p.providers {
|
for _, prov := range p.providers {
|
||||||
acct, err := prov.ResolveAccount(ctx)
|
acct, err := prov.ResolveAccount(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var blockErr *extcred.BlockError
|
||||||
|
// Only the env (direct-credential) provider maps an incomplete
|
||||||
|
// block to app_credential_incomplete. Other providers' blocks
|
||||||
|
// propagate unchanged so they still stop the chain (§3 step 1
|
||||||
|
// is specifically about direct app credential env vars).
|
||||||
|
if errors.As(err, &blockErr) && prov.Name() == directCredentialProviderName {
|
||||||
|
if missing := missingDirectCredentialKeys(); len(missing) > 0 {
|
||||||
|
return nil, errs.NewConfigError(errs.SubtypeAppCredentialIncomplete,
|
||||||
|
"direct app credential is incomplete").
|
||||||
|
WithMissingKeys(missing...).
|
||||||
|
WithHint("set both %s and %s, or unset both and use --profile / a config default.",
|
||||||
|
envvars.CliAppID, envvars.CliAppSecret)
|
||||||
|
}
|
||||||
|
// Block for a reason other than incompleteness (e.g. an
|
||||||
|
// invalid identity/strict-mode value); preserve prior behavior.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if acct != nil {
|
if acct != nil {
|
||||||
internal := convertAccount(acct)
|
// Only the env (direct-credential) provider feeds profile
|
||||||
source := extensionTokenSource{provider: prov}
|
// arbitration / conflict detection / DirectCredentialEnv reporting.
|
||||||
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
// This mirrors the block-path guard above. A non-env extension
|
||||||
if p.warnOut != nil {
|
// provider (e.g. sidecar) is NOT a direct-credential env account:
|
||||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
// it wins outright here, returning its account + token source
|
||||||
|
// unchanged (pre-diff behavior), without being misreported as a
|
||||||
|
// direct env credential (§4.2: Present = direct env vars actually
|
||||||
|
// set) or triggering a spurious profile_app_credential_conflict.
|
||||||
|
if prov.Name() != directCredentialProviderName {
|
||||||
|
internal := convertAccount(acct)
|
||||||
|
source := extensionTokenSource{provider: prov}
|
||||||
|
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
||||||
|
if p.warnOut != nil {
|
||||||
|
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
||||||
|
}
|
||||||
|
// enrichUserInfo failure is non-fatal: SupportedIdentities
|
||||||
|
// (used for strict mode) is already set by the provider.
|
||||||
|
// Clear unverified user identity for safety.
|
||||||
|
internal.UserOpenId = ""
|
||||||
|
internal.UserName = ""
|
||||||
}
|
}
|
||||||
// enrichUserInfo failure is non-fatal: SupportedIdentities
|
p.selectedSource = source
|
||||||
// (used for strict mode) is already set by the provider.
|
return internal, nil
|
||||||
// Clear unverified user identity for safety.
|
|
||||||
internal.UserOpenId = ""
|
|
||||||
internal.UserName = ""
|
|
||||||
}
|
}
|
||||||
p.selectedSource = source
|
envAcct = convertAccount(acct)
|
||||||
return internal, nil
|
envSource = extensionTokenSource{provider: prov}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if p.defaultAcct != nil {
|
|
||||||
|
// Step 2 (spec §3): an explicit profile was requested.
|
||||||
|
if p.profile != "" {
|
||||||
|
multi, loadErr := core.LoadMultiAppConfig()
|
||||||
|
if errors.Is(loadErr, core.ErrMalformedConfig) {
|
||||||
|
// A malformed config must not be masked as profile_not_found (which
|
||||||
|
// would tell the user to run `profile list` and hide a real config
|
||||||
|
// problem). Pass the underlying error through unchanged so
|
||||||
|
// errors.Is / errors.Unwrap keep working. An absent config is not
|
||||||
|
// malformed and still falls through to the friendly
|
||||||
|
// profile_not_found below, since the requested profile cannot exist.
|
||||||
|
return nil, loadErr
|
||||||
|
}
|
||||||
|
var app *core.AppConfig
|
||||||
|
if loadErr == nil && multi != nil {
|
||||||
|
app = multi.FindApp(p.profile)
|
||||||
|
}
|
||||||
|
if app == nil {
|
||||||
|
return nil, errs.NewConfigError(errs.SubtypeProfileNotFound,
|
||||||
|
"profile %q not found", p.profile).
|
||||||
|
WithProfile(p.profile).
|
||||||
|
WithCredentialSource(string(p.profileSource())).
|
||||||
|
WithHint("run `lark-cli profile list` to see available profiles.")
|
||||||
|
}
|
||||||
|
if envAcct != nil {
|
||||||
|
// E == complete: the direct env app_id must match the profile.
|
||||||
|
if app.AppId != envAcct.AppID {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict,
|
||||||
|
"profile %q app_id does not match %s", p.profile, envvars.CliAppID).
|
||||||
|
WithProfileAppConflict(app.AppId, envAcct.AppID).
|
||||||
|
WithHint("unset %s/%s, or select a profile whose app_id matches the environment.",
|
||||||
|
envvars.CliAppID, envvars.CliAppSecret)
|
||||||
|
}
|
||||||
|
p.selection = IdentitySelection{
|
||||||
|
Source: p.profileSource(),
|
||||||
|
DirectCredentialEnv: DirectCredentialEnv{
|
||||||
|
Present: true,
|
||||||
|
Keys: presentDirectCredentialKeys(),
|
||||||
|
AppID: envAcct.AppID,
|
||||||
|
Matched: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p.selection = IdentitySelection{
|
||||||
|
Source: p.profileSource(),
|
||||||
|
DirectCredentialEnv: DirectCredentialEnv{Present: false},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Resolve the profile's own (keychain-backed) credential locally.
|
||||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
// SECURITY (§5.1): generic message — never embed the underlying
|
||||||
|
// error or any secret material.
|
||||||
|
p.selection = IdentitySelection{}
|
||||||
|
return nil, errs.NewConfigError(errs.SubtypeProfileSecretInvalid,
|
||||||
|
"profile %q credential could not be resolved locally", p.profile).
|
||||||
|
WithProfile(p.profile).
|
||||||
|
WithAppID(app.AppId).
|
||||||
|
WithHint("verify the profile's app secret or re-add the profile with `lark-cli config`.")
|
||||||
}
|
}
|
||||||
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
||||||
return acct, nil
|
return acct, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 3 (spec §3): no explicit profile — direct env credential wins.
|
||||||
|
if envAcct != nil {
|
||||||
|
if err := p.enrichUserInfo(ctx, envAcct, envSource); err != nil {
|
||||||
|
if p.warnOut != nil {
|
||||||
|
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", envSource.Name(), err)
|
||||||
|
}
|
||||||
|
// enrichUserInfo failure is non-fatal: SupportedIdentities
|
||||||
|
// (used for strict mode) is already set by the provider.
|
||||||
|
// Clear unverified user identity for safety.
|
||||||
|
envAcct.UserOpenId = ""
|
||||||
|
envAcct.UserName = ""
|
||||||
|
}
|
||||||
|
p.selectedSource = envSource
|
||||||
|
p.selection = IdentitySelection{
|
||||||
|
Source: SourceEnvAppID,
|
||||||
|
DirectCredentialEnv: DirectCredentialEnv{
|
||||||
|
Present: true,
|
||||||
|
Keys: presentDirectCredentialKeys(),
|
||||||
|
AppID: envAcct.AppID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return envAcct, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No direct env credential and no profile → the config default.
|
||||||
|
if p.defaultAcct != nil {
|
||||||
|
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// The config default failed to resolve. Distinguish (spec §3 step
|
||||||
|
// 3.2): a default profile that EXISTS (has an app_id) but whose
|
||||||
|
// secret cannot be resolved locally is a profile_secret_invalid —
|
||||||
|
// "identity is configured, its secret is broken" is more actionable
|
||||||
|
// than "no active profile". Only when there is genuinely no usable
|
||||||
|
// default profile do we report no_active_profile. Other typed
|
||||||
|
// failures (e.g. a specific config error) pass through unchanged.
|
||||||
|
if prob, ok := errs.ProblemOf(err); ok && prob.Subtype == errs.SubtypeNotConfigured {
|
||||||
|
if name, appID, ok := defaultProfileIdentity(); ok {
|
||||||
|
// SECURITY (§5.1): generic message — never embed the
|
||||||
|
// underlying error or any secret material. app_id is
|
||||||
|
// plaintext and safe to echo.
|
||||||
|
return nil, errs.NewConfigError(errs.SubtypeProfileSecretInvalid,
|
||||||
|
"profile %q credential could not be resolved locally", name).
|
||||||
|
WithProfile(name).
|
||||||
|
WithAppID(appID).
|
||||||
|
WithHint("verify the profile's app secret or re-add the profile with `lark-cli config`.")
|
||||||
|
}
|
||||||
|
return nil, errs.NewConfigError(errs.SubtypeNoActiveProfile, "no active profile").
|
||||||
|
WithCredentialSource(noActiveProfileCredentialSource).
|
||||||
|
WithHint("run `lark-cli config init` / `lark-cli profile add`, or set %s.", envvars.CliProfile)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
multi, _ := core.LoadMultiAppConfig()
|
||||||
|
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
||||||
|
p.selection = IdentitySelection{Source: selectionSourceForDefault(multi)}
|
||||||
|
return acct, nil
|
||||||
|
}
|
||||||
return nil, core.NotConfiguredError()
|
return nil, core.NotConfiguredError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// profileSource reports the credential source kind for a profile-backed
|
||||||
|
// selection, discriminating the --profile flag from the env fallback.
|
||||||
|
func (p *CredentialProvider) profileSource() CredentialSourceKind {
|
||||||
|
if p.profileFromFlag {
|
||||||
|
return SourceFlagProfile
|
||||||
|
}
|
||||||
|
return SourceEnvProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
// noActiveProfileCredentialSource is the credential_source reported on the
|
||||||
|
// no_active_profile error. Spec §5 fixes this to the literal "config": there is
|
||||||
|
// no resolved default profile at all, so the more specific config:currentApp /
|
||||||
|
// config:firstApp source values (used on successful config-default selections)
|
||||||
|
// would be misleading. It is an enum string, never a secret.
|
||||||
|
const noActiveProfileCredentialSource = "config"
|
||||||
|
|
||||||
|
// defaultProfileIdentity reports the config default profile's display name and
|
||||||
|
// app_id when a usable default profile actually EXISTS (currentApp > firstApp
|
||||||
|
// resolves to an app with a non-empty app_id). It never touches the keychain or
|
||||||
|
// any secret, so it can distinguish "default profile exists but its secret is
|
||||||
|
// broken" (→ profile_secret_invalid) from "no usable default profile at all"
|
||||||
|
// (→ no_active_profile), without risking a secret leak (§5.1).
|
||||||
|
func defaultProfileIdentity() (name, appID string, ok bool) {
|
||||||
|
multi, err := core.LoadMultiAppConfig()
|
||||||
|
if err != nil || multi == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
app := multi.CurrentAppConfig("")
|
||||||
|
if app == nil || app.AppId == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return app.ProfileName(), app.AppId, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectionSourceForDefault reports whether the config default resolved to the
|
||||||
|
// explicit currentApp or fell back to the first app (spec §3 step 3.2).
|
||||||
|
func selectionSourceForDefault(multi *core.MultiAppConfig) CredentialSourceKind {
|
||||||
|
if multi != nil && multi.CurrentApp != "" {
|
||||||
|
return SourceConfigCurrentApp
|
||||||
|
}
|
||||||
|
return SourceConfigFirstApp
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingDirectCredentialKeys returns the NAMES (never values) of the direct
|
||||||
|
// app credential env vars that are absent. Used only when the env provider
|
||||||
|
// blocks, to map an incomplete direct credential to app_credential_incomplete.
|
||||||
|
func missingDirectCredentialKeys() []string {
|
||||||
|
var missing []string
|
||||||
|
if os.Getenv(envvars.CliAppID) == "" {
|
||||||
|
missing = append(missing, envvars.CliAppID)
|
||||||
|
}
|
||||||
|
if os.Getenv(envvars.CliAppSecret) == "" {
|
||||||
|
missing = append(missing, envvars.CliAppSecret)
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// presentDirectCredentialKeys returns the NAMES (never values) of the direct
|
||||||
|
// app credential env vars that are set. Used to annotate DirectCredentialEnv.
|
||||||
|
func presentDirectCredentialKeys() []string {
|
||||||
|
var keys []string
|
||||||
|
if os.Getenv(envvars.CliAppID) != "" {
|
||||||
|
keys = append(keys, envvars.CliAppID)
|
||||||
|
}
|
||||||
|
if os.Getenv(envvars.CliAppSecret) != "" {
|
||||||
|
keys = append(keys, envvars.CliAppSecret)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection resolves the account (once) and returns the cached, secret-free
|
||||||
|
// explanation of how the credential/App was selected. It mirrors
|
||||||
|
// selectedCredentialSource: resolve-then-return.
|
||||||
|
func (p *CredentialProvider) Selection(ctx context.Context) (IdentitySelection, error) {
|
||||||
|
if _, err := p.ResolveAccount(ctx); err != nil {
|
||||||
|
return IdentitySelection{}, err
|
||||||
|
}
|
||||||
|
return p.selection, nil
|
||||||
|
}
|
||||||
|
|
||||||
// enrichUserInfo resolves user identity when extension provides a UAT.
|
// enrichUserInfo resolves user identity when extension provides a UAT.
|
||||||
// If UAT is available, user_info API call is mandatory (security: verify token validity).
|
// If UAT is available, user_info API call is mandatory (security: verify token validity).
|
||||||
// If no UAT from extension, falls back to provider-supplied OpenID.
|
// If no UAT from extension, falls back to provider-supplied OpenID.
|
||||||
|
|||||||
554
internal/credential/credential_provider_selection_test.go
Normal file
554
internal/credential/credential_provider_selection_test.go
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package credential_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
extcred "github.com/larksuite/cli/extension/credential"
|
||||||
|
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/credential"
|
||||||
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func asConfigError(t *testing.T, err error) *errs.ConfigError {
|
||||||
|
t.Helper()
|
||||||
|
var ce *errs.ConfigError
|
||||||
|
if !errors.As(err, &ce) {
|
||||||
|
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
return ce
|
||||||
|
}
|
||||||
|
|
||||||
|
func asValidationError(t *testing.T, err error) *errs.ValidationError {
|
||||||
|
t.Helper()
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
return ve
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretValue is the profile secret written to config. It must NEVER appear in
|
||||||
|
// any error message or IdentitySelection (security §5.1).
|
||||||
|
const secretValue = "your-secret"
|
||||||
|
|
||||||
|
// envSecretValue is the direct env app secret. Same no-leak guarantee.
|
||||||
|
const envSecretValue = "your-password"
|
||||||
|
|
||||||
|
// writeConfigTenantA writes a config with a single profile "tenant_a" (app_id
|
||||||
|
// "cli_a"). The secret is a plaintext secret stored in config, which resolves
|
||||||
|
// locally without a keychain lookup.
|
||||||
|
func writeConfigTenantA(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
multi := &core.MultiAppConfig{
|
||||||
|
CurrentApp: "tenant_a",
|
||||||
|
Apps: []core.AppConfig{{
|
||||||
|
Name: "tenant_a",
|
||||||
|
AppId: "cli_a",
|
||||||
|
AppSecret: core.PlainSecret(secretValue),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeConfigTenantABroken writes tenant_a with a keychain-backed secret ref
|
||||||
|
// that cannot be resolved (noop keychain returns empty), so profile secret
|
||||||
|
// resolution fails locally.
|
||||||
|
func writeConfigTenantABroken(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
// A keychain SecretRef whose key does NOT match app_id cli_a. Local secret
|
||||||
|
// resolution fails (ValidateSecretKeyMatch), exercising profile_secret_invalid.
|
||||||
|
multi := &core.MultiAppConfig{
|
||||||
|
CurrentApp: "tenant_a",
|
||||||
|
Apps: []core.AppConfig{{
|
||||||
|
Name: "tenant_a",
|
||||||
|
AppId: "cli_a",
|
||||||
|
AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:wrong_key"}},
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProvider(t *testing.T, profile string, fromFlag bool) *credential.CredentialProvider {
|
||||||
|
t.Helper()
|
||||||
|
ep := &envprovider.Provider{}
|
||||||
|
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, profile)
|
||||||
|
cp := credential.NewCredentialProvider([]extcred.Provider{ep}, defaultAcct, nil, nil)
|
||||||
|
cp.WithProfile(profile, fromFlag)
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoSecretLeak fails if any secret value appears in the given strings.
|
||||||
|
func assertNoSecretLeak(t *testing.T, where string, vals ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, v := range vals {
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(v, secretValue) {
|
||||||
|
t.Errorf("%s leaked profile secret: %q", where, v)
|
||||||
|
}
|
||||||
|
if strings.Contains(v, envSecretValue) {
|
||||||
|
t.Errorf("%s leaked env secret: %q", where, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func subtypeOf(t *testing.T, err error) errs.Subtype {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("error is not a typed problem: %v", err)
|
||||||
|
}
|
||||||
|
return p.Subtype
|
||||||
|
}
|
||||||
|
|
||||||
|
// State #2: P none, E none, C none -> no_active_profile.
|
||||||
|
func TestSelection_State2_NoActiveProfile(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "")
|
||||||
|
t.Setenv(envvars.CliAppSecret, "")
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) // empty dir -> no config
|
||||||
|
cp := newProvider(t, "", false)
|
||||||
|
|
||||||
|
sel, err := cp.Selection(context.Background())
|
||||||
|
if got := subtypeOf(t, err); got != errs.SubtypeNoActiveProfile {
|
||||||
|
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeNoActiveProfile)
|
||||||
|
}
|
||||||
|
// Defect 1 (spec §5): no_active_profile must carry credential_source=config.
|
||||||
|
ce := asConfigError(t, err)
|
||||||
|
if ce.CredentialSource != "config" {
|
||||||
|
t.Errorf("credential_source = %q, want %q", ce.CredentialSource, "config")
|
||||||
|
}
|
||||||
|
assertNoSecretLeak(t, "state2", err.Error(), string(sel.Source))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config-default profile with a broken secret: P none, E none, C present but the
|
||||||
|
// default profile's keychain secret ref is corrupted. Per spec §3 step 3.2 this
|
||||||
|
// must be profile_secret_invalid (the identity IS configured, only its secret is
|
||||||
|
// broken) — NOT no_active_profile (which is reserved for "no usable default").
|
||||||
|
func TestSelection_ConfigDefaultBrokenSecret_ProfileSecretInvalid(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "")
|
||||||
|
t.Setenv(envvars.CliAppSecret, "")
|
||||||
|
writeConfigTenantABroken(t) // CurrentApp = tenant_a (app_id cli_a), broken keychain ref
|
||||||
|
cp := newProvider(t, "", false)
|
||||||
|
|
||||||
|
_, err := cp.Selection(context.Background())
|
||||||
|
if got := subtypeOf(t, err); got != errs.SubtypeProfileSecretInvalid {
|
||||||
|
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileSecretInvalid)
|
||||||
|
}
|
||||||
|
ce := asConfigError(t, err)
|
||||||
|
if ce.Profile != "tenant_a" {
|
||||||
|
t.Errorf("profile = %q, want tenant_a", ce.Profile)
|
||||||
|
}
|
||||||
|
if ce.AppID != "cli_a" {
|
||||||
|
t.Errorf("app_id = %q, want cli_a", ce.AppID)
|
||||||
|
}
|
||||||
|
// §5.1: generic message, no cause, no secret anywhere.
|
||||||
|
if errors.Unwrap(ce) != nil {
|
||||||
|
t.Errorf("profile_secret_invalid must not attach a cause, got %v", errors.Unwrap(ce))
|
||||||
|
}
|
||||||
|
assertNoSecretLeak(t, "config-default-broken", ce.Message, ce.Hint, ce.AppID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit profile requested but the config file is malformed. The load error
|
||||||
|
// must be propagated (errors.Is ErrMalformedConfig) rather than masked as
|
||||||
|
// profile_not_found, which would hide a real config problem and misdirect the
|
||||||
|
// user to `profile list`. An absent config is separately still profile_not_found.
|
||||||
|
func TestSelection_ExplicitProfile_MalformedConfig_PropagatesError(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "")
|
||||||
|
t.Setenv(envvars.CliAppSecret, "")
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
if err := os.MkdirAll(core.GetConfigDir(), 0o700); err != nil {
|
||||||
|
t.Fatalf("mkdir config dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(core.GetConfigPath(), []byte("{ this is not valid json"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write malformed config: %v", err)
|
||||||
|
}
|
||||||
|
cp := newProvider(t, "tenant_a", true)
|
||||||
|
|
||||||
|
_, err := cp.Selection(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for malformed config, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, core.ErrMalformedConfig) {
|
||||||
|
t.Fatalf("malformed config error not propagated: %v", err)
|
||||||
|
}
|
||||||
|
if prob, ok := errs.ProblemOf(err); ok && prob.Subtype == errs.SubtypeProfileNotFound {
|
||||||
|
t.Fatalf("malformed config masked as profile_not_found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State #3: P none, E partial (only APP_ID) -> app_credential_incomplete.
|
||||||
|
func TestSelection_State3_EnvPartial(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "cli_env")
|
||||||
|
t.Setenv(envvars.CliAppSecret, "")
|
||||||
|
writeConfigTenantA(t)
|
||||||
|
cp := newProvider(t, "", false)
|
||||||
|
|
||||||
|
_, err := cp.Selection(context.Background())
|
||||||
|
if got := subtypeOf(t, err); got != errs.SubtypeAppCredentialIncomplete {
|
||||||
|
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeAppCredentialIncomplete)
|
||||||
|
}
|
||||||
|
prob, _ := errs.ProblemOf(err)
|
||||||
|
ce := asConfigError(t, err)
|
||||||
|
if !slices.Contains(ce.MissingKeys, envvars.CliAppSecret) {
|
||||||
|
t.Errorf("missing_keys = %v, want to contain %q", ce.MissingKeys, envvars.CliAppSecret)
|
||||||
|
}
|
||||||
|
// missing_keys must be NAMES only, never values.
|
||||||
|
for _, k := range ce.MissingKeys {
|
||||||
|
if strings.Contains(k, envSecretValue) || strings.Contains(k, secretValue) {
|
||||||
|
t.Errorf("missing_keys contains a value, not a name: %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNoSecretLeak(t, "state3", prob.Message, prob.Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// State #4: P none, E complete -> env:LARKSUITE_CLI_APP_ID.
|
||||||
|
func TestSelection_State4_EnvComplete(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "cli_env")
|
||||||
|
t.Setenv(envvars.CliAppSecret, envSecretValue)
|
||||||
|
writeConfigTenantA(t)
|
||||||
|
cp := newProvider(t, "", false)
|
||||||
|
|
||||||
|
sel, err := cp.Selection(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if sel.Source != credential.SourceEnvAppID {
|
||||||
|
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceEnvAppID)
|
||||||
|
}
|
||||||
|
if !sel.DirectCredentialEnv.Present {
|
||||||
|
t.Errorf("DirectCredentialEnv.Present = false, want true")
|
||||||
|
}
|
||||||
|
assertNoSecretLeak(t, "state4", string(sel.Source), sel.DirectCredentialEnv.AppID)
|
||||||
|
assertNoSecretLeak(t, "state4-keys", sel.DirectCredentialEnv.Keys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// State #5: P valid, E none -> flag:--profile (fromFlag) source.
|
||||||
|
func TestSelection_State5_ProfileOnly(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "")
|
||||||
|
t.Setenv(envvars.CliAppSecret, "")
|
||||||
|
writeConfigTenantA(t)
|
||||||
|
cp := newProvider(t, "tenant_a", true)
|
||||||
|
|
||||||
|
sel, err := cp.Selection(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if sel.Source != credential.SourceFlagProfile {
|
||||||
|
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceFlagProfile)
|
||||||
|
}
|
||||||
|
if sel.DirectCredentialEnv.Present {
|
||||||
|
t.Errorf("DirectCredentialEnv.Present = true, want false")
|
||||||
|
}
|
||||||
|
assertNoSecretLeak(t, "state5", string(sel.Source))
|
||||||
|
}
|
||||||
|
|
||||||
|
// State #5b: P valid from env (not flag) -> env:LARKSUITE_CLI_PROFILE source.
|
||||||
|
func TestSelection_State5_ProfileFromEnv(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "")
|
||||||
|
t.Setenv(envvars.CliAppSecret, "")
|
||||||
|
writeConfigTenantA(t)
|
||||||
|
cp := newProvider(t, "tenant_a", false)
|
||||||
|
|
||||||
|
sel, err := cp.Selection(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if sel.Source != credential.SourceEnvProfile {
|
||||||
|
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceEnvProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State #6: P missing (nonexistent), E complete -> profile_not_found.
|
||||||
|
func TestSelection_State6_ProfileNotFound(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "cli_env")
|
||||||
|
t.Setenv(envvars.CliAppSecret, envSecretValue)
|
||||||
|
writeConfigTenantA(t)
|
||||||
|
cp := newProvider(t, "does_not_exist", true)
|
||||||
|
|
||||||
|
sel, err := cp.Selection(context.Background())
|
||||||
|
if got := subtypeOf(t, err); got != errs.SubtypeProfileNotFound {
|
||||||
|
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileNotFound)
|
||||||
|
}
|
||||||
|
prob, _ := errs.ProblemOf(err)
|
||||||
|
// Defect 1 (spec §5): profile_not_found must carry the credential_source that
|
||||||
|
// named the profile — here the --profile flag.
|
||||||
|
ce := asConfigError(t, err)
|
||||||
|
if ce.CredentialSource != string(credential.SourceFlagProfile) {
|
||||||
|
t.Errorf("credential_source = %q, want %q", ce.CredentialSource, credential.SourceFlagProfile)
|
||||||
|
}
|
||||||
|
assertNoSecretLeak(t, "state6", err.Error(), prob.Hint, string(sel.Source))
|
||||||
|
}
|
||||||
|
|
||||||
|
// State #7: P valid but secret broken, E none -> profile_secret_invalid.
|
||||||
|
func TestSelection_State7_ProfileSecretInvalid(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "")
|
||||||
|
t.Setenv(envvars.CliAppSecret, "")
|
||||||
|
writeConfigTenantABroken(t)
|
||||||
|
cp := newProvider(t, "tenant_a", true)
|
||||||
|
|
||||||
|
_, err := cp.Selection(context.Background())
|
||||||
|
if got := subtypeOf(t, err); got != errs.SubtypeProfileSecretInvalid {
|
||||||
|
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileSecretInvalid)
|
||||||
|
}
|
||||||
|
ce := asConfigError(t, err)
|
||||||
|
if ce.Profile != "tenant_a" {
|
||||||
|
t.Errorf("profile = %q, want tenant_a", ce.Profile)
|
||||||
|
}
|
||||||
|
if ce.AppID != "cli_a" {
|
||||||
|
t.Errorf("app_id = %q, want cli_a", ce.AppID)
|
||||||
|
}
|
||||||
|
assertNoSecretLeak(t, "state7", ce.Message, ce.Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretMarkerValue is a distinctive string used to prove that the
|
||||||
|
// profile_secret_invalid path drops the underlying error entirely, even when
|
||||||
|
// that underlying error's own message CONTAINS a secret. Unlike
|
||||||
|
// writeConfigTenantABroken (whose noop-keychain failure is a harmless empty
|
||||||
|
// error), this uses a custom DefaultAccountResolver whose error text embeds
|
||||||
|
// the marker, closing the gap where a leak could hide in a cause chain that
|
||||||
|
// happens to be empty in the noop-keychain case.
|
||||||
|
const secretMarkerValue = "your-access-token"
|
||||||
|
|
||||||
|
// leakingSecretResolver is a DefaultAccountResolver stub whose ResolveAccount
|
||||||
|
// fails with an error whose message contains secretMarkerValue, simulating a
|
||||||
|
// real keychain/secret-resolution failure that echoes back sensitive material
|
||||||
|
// (e.g. a keychain library including the attempted secret in its error text).
|
||||||
|
type leakingSecretResolver struct{}
|
||||||
|
|
||||||
|
func (leakingSecretResolver) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||||
|
return nil, fmt.Errorf("keychain decode failed for secret %s", secretMarkerValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// State #7 (secret-bearing underlying error): P valid, but the underlying
|
||||||
|
// account/secret resolution fails with an error that itself contains a
|
||||||
|
// secret. This locks the §5.1 design: doResolveAccount emits a generic
|
||||||
|
// profile_secret_invalid ConfigError WITHOUT attaching the underlying cause,
|
||||||
|
// so a secret embedded in that underlying error can never surface through
|
||||||
|
// err.Error(), Message, Hint, the unwrapped cause chain, or Selection().
|
||||||
|
func TestSelection_State7_UnderlyingErrorContainingSecret_NotLeaked(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "")
|
||||||
|
t.Setenv(envvars.CliAppSecret, "")
|
||||||
|
writeConfigTenantA(t) // profile "tenant_a" exists with app_id "cli_a"
|
||||||
|
|
||||||
|
ep := &envprovider.Provider{}
|
||||||
|
cp := credential.NewCredentialProvider([]extcred.Provider{ep}, leakingSecretResolver{}, nil, nil)
|
||||||
|
cp.WithProfile("tenant_a", true)
|
||||||
|
|
||||||
|
sel, err := cp.Selection(context.Background())
|
||||||
|
if got := subtypeOf(t, err); got != errs.SubtypeProfileSecretInvalid {
|
||||||
|
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileSecretInvalid)
|
||||||
|
}
|
||||||
|
ce := asConfigError(t, err)
|
||||||
|
if ce.Profile != "tenant_a" {
|
||||||
|
t.Errorf("profile = %q, want tenant_a", ce.Profile)
|
||||||
|
}
|
||||||
|
if ce.AppID != "cli_a" {
|
||||||
|
t.Errorf("app_id = %q, want cli_a", ce.AppID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the full unwrap chain. This is the assertion that would catch a
|
||||||
|
// regression where the profile_secret_invalid branch starts attaching the
|
||||||
|
// underlying error via WithCause: if it did, this loop would find the
|
||||||
|
// marker in a wrapped link even though err.Error()/Message/Hint (which
|
||||||
|
// only reflect the top-level ConfigError, not the chain) might look clean.
|
||||||
|
for cur := error(ce); cur != nil; cur = errors.Unwrap(cur) {
|
||||||
|
if strings.Contains(cur.Error(), secretMarkerValue) {
|
||||||
|
t.Errorf("cause chain leaked secret marker: %v", cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(err.Error(), secretMarkerValue) {
|
||||||
|
t.Errorf("err.Error() leaked secret marker: %q", err.Error())
|
||||||
|
}
|
||||||
|
if strings.Contains(ce.Message, secretMarkerValue) {
|
||||||
|
t.Errorf("Message leaked secret marker: %q", ce.Message)
|
||||||
|
}
|
||||||
|
if strings.Contains(ce.Hint, secretMarkerValue) {
|
||||||
|
t.Errorf("Hint leaked secret marker: %q", ce.Hint)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(sel.Source), secretMarkerValue) {
|
||||||
|
t.Errorf("Selection.Source leaked secret marker: %q", sel.Source)
|
||||||
|
}
|
||||||
|
if strings.Contains(sel.DirectCredentialEnv.AppID, secretMarkerValue) {
|
||||||
|
t.Errorf("Selection.DirectCredentialEnv.AppID leaked secret marker: %q", sel.DirectCredentialEnv.AppID)
|
||||||
|
}
|
||||||
|
for _, k := range sel.DirectCredentialEnv.Keys {
|
||||||
|
if strings.Contains(k, secretMarkerValue) {
|
||||||
|
t.Errorf("Selection.DirectCredentialEnv.Keys leaked secret marker: %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// State #7 always clears p.selection on the secret-invalid path (see
|
||||||
|
// doResolveAccount); assert it is zero-valued, which trivially implies no
|
||||||
|
// marker anywhere in it and guards against a future field being populated
|
||||||
|
// from the failed resolution.
|
||||||
|
if sel.Source != "" || sel.DirectCredentialEnv.Present ||
|
||||||
|
sel.DirectCredentialEnv.AppID != "" || len(sel.DirectCredentialEnv.Keys) != 0 {
|
||||||
|
t.Errorf("Selection() = %+v, want zero value on profile_secret_invalid", sel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State #8: P valid, E complete, app_id matches -> profile source, env present+matched.
|
||||||
|
func TestSelection_State8_ProfileMatchesEnv(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "cli_a") // matches profile app_id
|
||||||
|
t.Setenv(envvars.CliAppSecret, envSecretValue)
|
||||||
|
writeConfigTenantA(t)
|
||||||
|
cp := newProvider(t, "tenant_a", true)
|
||||||
|
|
||||||
|
sel, err := cp.Selection(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if sel.Source != credential.SourceFlagProfile {
|
||||||
|
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceFlagProfile)
|
||||||
|
}
|
||||||
|
if !sel.DirectCredentialEnv.Present || !sel.DirectCredentialEnv.Matched {
|
||||||
|
t.Fatalf("DirectCredentialEnv = %+v, want Present && Matched", sel.DirectCredentialEnv)
|
||||||
|
}
|
||||||
|
if sel.DirectCredentialEnv.AppID != "cli_a" {
|
||||||
|
t.Errorf("DirectCredentialEnv.AppID = %q, want cli_a", sel.DirectCredentialEnv.AppID)
|
||||||
|
}
|
||||||
|
assertNoSecretLeak(t, "state8", string(sel.Source), sel.DirectCredentialEnv.AppID)
|
||||||
|
assertNoSecretLeak(t, "state8-keys", sel.DirectCredentialEnv.Keys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// State #9: P valid, E complete, app_id mismatches -> profile_app_credential_conflict.
|
||||||
|
func TestSelection_State9_Conflict(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "cli_x") // mismatches profile app_id cli_a
|
||||||
|
t.Setenv(envvars.CliAppSecret, envSecretValue)
|
||||||
|
writeConfigTenantA(t)
|
||||||
|
cp := newProvider(t, "tenant_a", true)
|
||||||
|
|
||||||
|
_, err := cp.Selection(context.Background())
|
||||||
|
if got := subtypeOf(t, err); got != errs.SubtypeProfileAppCredentialConflict {
|
||||||
|
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileAppCredentialConflict)
|
||||||
|
}
|
||||||
|
ve := asValidationError(t, err)
|
||||||
|
if ve.ProfileAppID != "cli_a" {
|
||||||
|
t.Errorf("profile_app_id = %q, want cli_a", ve.ProfileAppID)
|
||||||
|
}
|
||||||
|
if ve.EnvAppID != "cli_x" {
|
||||||
|
t.Errorf("env_app_id = %q, want cli_x", ve.EnvAppID)
|
||||||
|
}
|
||||||
|
assertNoSecretLeak(t, "state9", ve.Message, ve.Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// State #10: P valid, E partial -> app_credential_incomplete (env-partial wins).
|
||||||
|
func TestSelection_State10_ProfileWithEnvPartial(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "")
|
||||||
|
t.Setenv(envvars.CliAppSecret, envSecretValue) // only secret set
|
||||||
|
writeConfigTenantA(t)
|
||||||
|
cp := newProvider(t, "tenant_a", true)
|
||||||
|
|
||||||
|
_, err := cp.Selection(context.Background())
|
||||||
|
if got := subtypeOf(t, err); got != errs.SubtypeAppCredentialIncomplete {
|
||||||
|
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeAppCredentialIncomplete)
|
||||||
|
}
|
||||||
|
ce := asConfigError(t, err)
|
||||||
|
if !slices.Contains(ce.MissingKeys, envvars.CliAppID) {
|
||||||
|
t.Errorf("missing_keys = %v, want to contain %q", ce.MissingKeys, envvars.CliAppID)
|
||||||
|
}
|
||||||
|
assertNoSecretLeak(t, "state10", ce.Message, ce.Hint)
|
||||||
|
assertNoSecretLeak(t, "state10-keys", ce.MissingKeys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeSidecarProvider is a NON-env extension provider (Priority 0, Name !=
|
||||||
|
// directCredentialProviderName) that always returns a non-nil account. It
|
||||||
|
// stands in for the sidecar extension provider without needing a build tag.
|
||||||
|
type fakeSidecarProvider struct {
|
||||||
|
appID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSidecarProvider) Name() string { return "sidecar" }
|
||||||
|
func (f *fakeSidecarProvider) Priority() int { return 0 }
|
||||||
|
func (f *fakeSidecarProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
|
||||||
|
return &extcred.Account{AppID: f.appID, Brand: extcred.Brand("feishu")}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeSidecarProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
|
||||||
|
return &extcred.Token{Value: "sidecar-tok", Source: "sidecar"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: a NON-env extension provider (sidecar) that returns an account
|
||||||
|
// must win outright even when a profile is set. It must NOT be treated as a
|
||||||
|
// direct-credential env account: no profile arbitration, no
|
||||||
|
// profile_app_credential_conflict (even though its app_id differs from the
|
||||||
|
// profile's cli_a), and DirectCredentialEnv.Present must stay false (§4.2 —
|
||||||
|
// no direct env vars are set). This proves the success-account provider gating
|
||||||
|
// mirrors the block-path guard.
|
||||||
|
func TestSelection_NonEnvExtensionProviderWinsOverProfile(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "") // no direct env credential
|
||||||
|
t.Setenv(envvars.CliAppSecret, "") // no direct env credential
|
||||||
|
writeConfigTenantA(t) // profile tenant_a exists, app_id cli_a
|
||||||
|
|
||||||
|
sidecar := &fakeSidecarProvider{appID: "sidecar_app"} // differs from cli_a
|
||||||
|
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "tenant_a")
|
||||||
|
cp := credential.NewCredentialProvider([]extcred.Provider{sidecar}, defaultAcct, nil, nil)
|
||||||
|
cp.WithProfile("tenant_a", true)
|
||||||
|
|
||||||
|
acct, err := cp.ResolveAccount(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// The sidecar account is used as-is, NOT overridden by profile arbitration.
|
||||||
|
if acct == nil || acct.AppID != "sidecar_app" {
|
||||||
|
t.Fatalf("account = %+v, want AppID sidecar_app (sidecar wins outright)", acct)
|
||||||
|
}
|
||||||
|
|
||||||
|
sel, err := cp.Selection(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected Selection error: %v", err)
|
||||||
|
}
|
||||||
|
// No misreported direct env credential (§4.2).
|
||||||
|
if sel.DirectCredentialEnv.Present {
|
||||||
|
t.Errorf("DirectCredentialEnv.Present = true, want false (no direct env vars set)")
|
||||||
|
}
|
||||||
|
// The mismatched app_id (sidecar_app vs profile cli_a) must NOT trigger a
|
||||||
|
// profile_app_credential_conflict: both ResolveAccount and Selection above
|
||||||
|
// returned nil errors, so no conflict (or any other) error was produced.
|
||||||
|
// Guard against a future regression that surfaces a conflict via Selection.
|
||||||
|
if _, selErr := cp.Selection(context.Background()); selErr != nil {
|
||||||
|
if subtypeOf(t, selErr) == errs.SubtypeProfileAppCredentialConflict {
|
||||||
|
t.Errorf("got profile_app_credential_conflict, want none for non-env provider")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNoSecretLeak(t, "nonenv-sidecar", string(sel.Source), sel.DirectCredentialEnv.AppID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// State #1: P none, E none, C present -> config default (currentApp).
|
||||||
|
func TestSelection_State1_ConfigDefault(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAppID, "")
|
||||||
|
t.Setenv(envvars.CliAppSecret, "")
|
||||||
|
writeConfigTenantA(t) // CurrentApp = tenant_a
|
||||||
|
cp := newProvider(t, "", false)
|
||||||
|
|
||||||
|
sel, err := cp.Selection(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if sel.Source != credential.SourceConfigCurrentApp {
|
||||||
|
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceConfigCurrentApp)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/credential/identity_selection.go
Normal file
43
internal/credential/identity_selection.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package credential
|
||||||
|
|
||||||
|
// CredentialSourceKind is the wire-stable App/credential selection source.
|
||||||
|
type CredentialSourceKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceFlagProfile CredentialSourceKind = "flag:--profile"
|
||||||
|
SourceEnvProfile CredentialSourceKind = "env:LARKSUITE_CLI_PROFILE"
|
||||||
|
SourceEnvAppID CredentialSourceKind = "env:LARKSUITE_CLI_APP_ID"
|
||||||
|
SourceConfigCurrentApp CredentialSourceKind = "config:currentApp"
|
||||||
|
SourceConfigFirstApp CredentialSourceKind = "config:firstApp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DirectCredentialEnv describes the state of direct app credential env vars.
|
||||||
|
// It never carries a secret value — only names and the non-sensitive app_id.
|
||||||
|
type DirectCredentialEnv struct {
|
||||||
|
Present bool `json:"present"`
|
||||||
|
Keys []string `json:"keys,omitempty"`
|
||||||
|
AppID string `json:"appId,omitempty"`
|
||||||
|
Matched bool `json:"matched,omitempty"`
|
||||||
|
ConflictsWithProfile bool `json:"conflictsWithProfile,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdentitySelection is the explainable result of credential selection.
|
||||||
|
// It carries NO secret value (security: §5.1).
|
||||||
|
type IdentitySelection struct {
|
||||||
|
Source CredentialSourceKind
|
||||||
|
DirectCredentialEnv DirectCredentialEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit reports whether the identity was actively specified by the
|
||||||
|
// user/agent (flag or env), which governs no-fallback behavior.
|
||||||
|
func (s IdentitySelection) Explicit() bool {
|
||||||
|
switch s.Source {
|
||||||
|
case SourceFlagProfile, SourceEnvProfile, SourceEnvAppID:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
25
internal/credential/identity_selection_test.go
Normal file
25
internal/credential/identity_selection_test.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package credential
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIdentitySelectionExplicit(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
src CredentialSourceKind
|
||||||
|
explicit bool
|
||||||
|
}{
|
||||||
|
{SourceFlagProfile, true},
|
||||||
|
{SourceEnvProfile, true},
|
||||||
|
{SourceEnvAppID, true},
|
||||||
|
{SourceConfigCurrentApp, false},
|
||||||
|
{SourceConfigFirstApp, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
sel := IdentitySelection{Source: c.src}
|
||||||
|
if sel.Explicit() != c.explicit {
|
||||||
|
t.Errorf("source %q: Explicit()=%v want %v", c.src, sel.Explicit(), c.explicit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ const (
|
|||||||
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
|
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
|
||||||
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
||||||
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
||||||
|
CliProfile = "LARKSUITE_CLI_PROFILE"
|
||||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||||
|
|
||||||
// Sidecar proxy (auth proxy mode)
|
// Sidecar proxy (auth proxy mode)
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
||||||
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
||||||
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
|
||||||
// depend on internal/core (which would create an import cycle).
|
// Brand through core.ParseBrand, so callers can pass a raw brand string without
|
||||||
|
// coupling this contract to core's brand enum.
|
||||||
type ClassifyContext struct {
|
type ClassifyContext struct {
|
||||||
Brand string // "feishu" | "lark" — drives console_url host
|
Brand string // "feishu" | "lark" — drives console_url host
|
||||||
AppID string // placed in console_url
|
AppID string // placed in console_url
|
||||||
@@ -444,28 +446,27 @@ func extractMissingScopes(resp map[string]any) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
|
||||||
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
|
||||||
// scopes list returns the bare /auth landing page; scopes are joined with
|
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
|
||||||
// commas in the `q` query parameter so the console can pre-select them.
|
// returns the page carrying only clientID; otherwise scopes are joined with
|
||||||
|
// commas in the `scopes` query parameter so the console can pre-select them.
|
||||||
//
|
//
|
||||||
// brand is "feishu" or "lark"; unknown values default to feishu.
|
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||||
if appID == "" {
|
if appID == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
host := "open.feishu.cn"
|
// QueryEscape both values — clientID and scopes both sit in the query
|
||||||
if brand == "lark" {
|
// string, and untrusted content must not be able to inject extra query
|
||||||
host = "open.larksuite.com"
|
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
|
||||||
}
|
// open-platform base URL stays a single source of truth.
|
||||||
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
|
||||||
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
|
||||||
// content must not be able to inject extra query parameters via `&`/`#`.
|
|
||||||
pathID := url.PathEscape(appID)
|
|
||||||
if len(scopes) == 0 {
|
if len(scopes) == 0 {
|
||||||
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
return base
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
|
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
|
||||||
}
|
}
|
||||||
|
|
||||||
func intFromAny(v any) int {
|
func intFromAny(v any) int {
|
||||||
|
|||||||
@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
|
||||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
|
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
||||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
|
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
|
|||||||
name: "ampersand in scope smuggles extra param",
|
name: "ampersand in scope smuggles extra param",
|
||||||
appID: "cli_good",
|
appID: "cli_good",
|
||||||
scopes: []string{"scope&evil=injected"},
|
scopes: []string{"scope&evil=injected"},
|
||||||
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
|
||||||
denyInURL: []string{"q=scope&evil=injected"},
|
denyInURL: []string{"scopes=scope&evil=injected"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hash in scope splits fragment",
|
name: "hash in scope splits fragment",
|
||||||
appID: "cli_good",
|
appID: "cli_good",
|
||||||
scopes: []string{"scope#fragment"},
|
scopes: []string{"scope#fragment"},
|
||||||
wantInURL: []string{"q=scope%23fragment"},
|
wantInURL: []string{"scopes=scope%23fragment"},
|
||||||
denyInURL: []string{"q=scope#fragment"},
|
denyInURL: []string{"scopes=scope#fragment"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "question mark in appID prematurely opens query",
|
name: "question mark in appID prematurely opens query",
|
||||||
appID: "good?q=injected",
|
appID: "good?q=injected",
|
||||||
scopes: []string{"docx:document"},
|
scopes: []string{"docx:document"},
|
||||||
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
|
||||||
denyInURL: []string{"/app/good?q=injected/auth"},
|
denyInURL: []string{"clientID=good?q=injected"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hash in appID truncates URL",
|
name: "hash in appID truncates URL",
|
||||||
appID: "good#fragment",
|
appID: "good#fragment",
|
||||||
scopes: []string{"docx:document"},
|
scopes: []string{"docx:document"},
|
||||||
wantInURL: []string{"/app/good%23fragment/auth"},
|
wantInURL: []string{"clientID=good%23fragment"},
|
||||||
denyInURL: []string{"/app/good#fragment/auth"},
|
denyInURL: []string{"clientID=good#fragment"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "slash in appID escapes path segment",
|
name: "slash in appID does not open a new path segment",
|
||||||
appID: "good/extra/segment",
|
appID: "good/extra/segment",
|
||||||
scopes: []string{"docx:document"},
|
scopes: []string{"docx:document"},
|
||||||
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
|
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
|
|||||||
if pe.MissingScopes != nil {
|
if pe.MissingScopes != nil {
|
||||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
|
||||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
|
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
|||||||
// at the app level — re-authenticating cannot fix it. The hint must
|
// at the app level — re-authenticating cannot fix it. The hint must
|
||||||
// point to the developer console regardless of caller identity, or
|
// point to the developer console regardless of caller identity, or
|
||||||
// agents will loop on `auth login` forever.
|
// agents will loop on `auth login` forever.
|
||||||
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
|
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
|
||||||
for _, identity := range []string{"user", "bot", ""} {
|
for _, identity := range []string{"user", "bot", ""} {
|
||||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
||||||
if !strings.Contains(got, "developer console") {
|
if !strings.Contains(got, "developer console") {
|
||||||
|
|||||||
@@ -10,8 +10,20 @@ import "github.com/larksuite/cli/errs"
|
|||||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||||
var driveCodeMeta = map[int]CodeMeta{
|
var driveCodeMeta = map[int]CodeMeta{
|
||||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||||
|
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||||
|
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||||
|
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||||
|
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||||
|
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||||
|
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||||
|
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||||
|
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||||
|
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||||
|
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||||
|
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||||
|
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ func TestLookupCodeMeta_DriveCodes(t *testing.T) {
|
|||||||
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
|
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
|
||||||
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
|
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
|
||||||
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
|
// Secure label endpoint codes observed from drive +secure-label-update
|
||||||
|
// failure telemetry.
|
||||||
|
{1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
|
{1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||||
|
{1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false},
|
||||||
|
{99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
|
{9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||||
|
|||||||
@@ -102,6 +102,35 @@ func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
code int
|
||||||
|
wantCat errs.Category
|
||||||
|
wantSubtype errs.Subtype
|
||||||
|
wantRetry bool
|
||||||
|
}{
|
||||||
|
{1061001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||||
|
{1061002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
|
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||||
|
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||||
|
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||||
|
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
|
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||||
|
got, ok := LookupCodeMeta(tc.code)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||||
|
}
|
||||||
|
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||||
|
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||||
|
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLookupCodeMeta_Unknown(t *testing.T) {
|
func TestLookupCodeMeta_Unknown(t *testing.T) {
|
||||||
_, ok := LookupCodeMeta(999999)
|
_, ok := LookupCodeMeta(999999)
|
||||||
if ok {
|
if ok {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
extcred "github.com/larksuite/cli/extension/credential"
|
||||||
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"
|
||||||
@@ -61,12 +62,131 @@ func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, veri
|
|||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
|
// An external provider mints tokens on demand and blocks interactive auth,
|
||||||
|
// so the built-in keychain heuristics and "auth login" hints don't apply.
|
||||||
|
if provider := activeExternalProvider(ctx, f); provider != "" {
|
||||||
|
return diagnoseExternal(ctx, f, cfg, provider, verify)
|
||||||
|
}
|
||||||
return Result{
|
return Result{
|
||||||
Bot: diagnoseBot(ctx, f, cfg, verify),
|
Bot: diagnoseBot(ctx, f, cfg, verify),
|
||||||
User: diagnoseUser(ctx, f, cfg, verify),
|
User: diagnoseUser(ctx, f, cfg, verify),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// activeExternalProvider returns the active extension provider name, or "".
|
||||||
|
// An error degrades to the built-in path: an unreachable provider would already
|
||||||
|
// have failed the f.Config() that produced cfg.
|
||||||
|
func activeExternalProvider(ctx context.Context, f *cmdutil.Factory) string {
|
||||||
|
if f == nil || f.Credential == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
name, err := f.Credential.ActiveExtensionProviderName(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, verify bool) Result {
|
||||||
|
if cfg == nil || cfg.AppID == "" {
|
||||||
|
notConfigured := Identity{
|
||||||
|
Status: StatusNotConfigured,
|
||||||
|
Message: "not configured (missing app config)",
|
||||||
|
Hint: externalCredentialHint(provider),
|
||||||
|
}
|
||||||
|
return Result{Bot: notConfigured, User: notConfigured}
|
||||||
|
}
|
||||||
|
// SupportedIdentities == 0 is "unspecified" — treat as both, per CanBot.
|
||||||
|
ids := extcred.IdentitySupport(cfg.SupportedIdentities)
|
||||||
|
supportsBot := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsBot)
|
||||||
|
supportsUser := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsUser)
|
||||||
|
return Result{
|
||||||
|
Bot: diagnoseExternalBot(ctx, f, cfg, provider, supportsBot, verify),
|
||||||
|
User: diagnoseExternalUser(ctx, f, cfg, provider, supportsUser, verify),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
|
||||||
|
if !supported {
|
||||||
|
return notProvidedExternally("Bot", provider)
|
||||||
|
}
|
||||||
|
id := Identity{Status: StatusReady, Available: true, Message: "Bot identity: ready (provided by " + provider + ")"}
|
||||||
|
if !verify {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
token, err := resolveBotToken(ctx, f, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return externalVerifyFailed(id, "Bot", provider, err)
|
||||||
|
}
|
||||||
|
info, err := fetchBotInfo(ctx, f, cfg, token)
|
||||||
|
if err != nil {
|
||||||
|
return externalVerifyFailed(id, "Bot", provider, err)
|
||||||
|
}
|
||||||
|
id.Verified = boolPtr(true)
|
||||||
|
id.OpenID = info.OpenID
|
||||||
|
id.AppName = info.AppName
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
|
||||||
|
if !supported {
|
||||||
|
return notProvidedExternally("User", provider)
|
||||||
|
}
|
||||||
|
// enrichUserInfo populates UserOpenId only after the provider returns and
|
||||||
|
// verifies a UAT (and clears it on failure), so a resolved open id is the
|
||||||
|
// external analogue of a keychain token being present.
|
||||||
|
if cfg.UserOpenId == "" {
|
||||||
|
return Identity{
|
||||||
|
Status: StatusMissing,
|
||||||
|
Message: "User identity: not signed in via credential source " + provider,
|
||||||
|
Hint: externalCredentialHint(provider),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id := Identity{
|
||||||
|
Status: StatusReady,
|
||||||
|
Available: true,
|
||||||
|
TokenStatus: StatusReady,
|
||||||
|
UserName: cfg.UserName,
|
||||||
|
OpenID: cfg.UserOpenId,
|
||||||
|
Message: "User identity: ready (provided by " + provider + ")",
|
||||||
|
}
|
||||||
|
if !verify {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
if _, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsUser, cfg.AppID)); err != nil {
|
||||||
|
return externalVerifyFailed(id, "User", provider, err)
|
||||||
|
}
|
||||||
|
id.Verified = boolPtr(true)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func notProvidedExternally(label, provider string) Identity {
|
||||||
|
return Identity{
|
||||||
|
Status: StatusNotConfigured,
|
||||||
|
Message: label + " identity: not provided by credential source " + provider,
|
||||||
|
Hint: externalCredentialHint(provider),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// externalVerifyFailed flips id to verify-failed, keeping any identity fields
|
||||||
|
// (open id, user name) already resolved before the probe.
|
||||||
|
func externalVerifyFailed(id Identity, label, provider string, err error) Identity {
|
||||||
|
id.Available = false
|
||||||
|
id.Verified = boolPtr(false)
|
||||||
|
id.Status = StatusVerifyFailed
|
||||||
|
id.TokenStatus = ""
|
||||||
|
id.Message = label + " identity: verify failed: " + err.Error()
|
||||||
|
id.Hint = externalCredentialHint(provider)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// externalCredentialHint reports the constraint, not a remediation: the
|
||||||
|
// identity is the provider's to manage, not lark-cli's to fix. What to do about
|
||||||
|
// it is the caller's call — there may be no user to ask.
|
||||||
|
func externalCredentialHint(provider string) string {
|
||||||
|
return fmt.Sprintf("managed by the external credential provider %q and cannot be configured via lark-cli", provider)
|
||||||
|
}
|
||||||
|
|
||||||
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
||||||
if cfg == nil || cfg.AppID == "" {
|
if cfg == nil || cfg.AppID == "" {
|
||||||
return Identity{
|
return Identity{
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
extcred "github.com/larksuite/cli/extension/credential"
|
||||||
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/credential"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
"github.com/zalando/go-keyring"
|
"github.com/zalando/go-keyring"
|
||||||
)
|
)
|
||||||
@@ -348,3 +350,136 @@ func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
|
|||||||
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
|
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fakeExtProvider is a minimal credential.extcred.Provider for exercising the
|
||||||
|
// external-credential diagnosis path. account makes the provider "active";
|
||||||
|
// token (when set) satisfies ResolveToken during verify.
|
||||||
|
type fakeExtProvider struct {
|
||||||
|
name string
|
||||||
|
account *extcred.Account
|
||||||
|
token *extcred.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakeExtProvider) Name() string { return p.name }
|
||||||
|
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||||
|
return p.account, nil
|
||||||
|
}
|
||||||
|
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||||
|
return p.token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalFactory(prov *fakeExtProvider, cfg *core.CliConfig) *cmdutil.Factory {
|
||||||
|
cred := credential.NewCredentialProvider(
|
||||||
|
[]extcred.Provider{prov}, nil, nil,
|
||||||
|
func() (*http.Client, error) { return nil, nil },
|
||||||
|
)
|
||||||
|
return &cmdutil.Factory{
|
||||||
|
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||||
|
Credential: cred,
|
||||||
|
IOStreams: &cmdutil.IOStreams{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertExternalHint locks the contract that an external-provider hint never
|
||||||
|
// points at interactive commands blocked under an external provider.
|
||||||
|
func assertExternalHint(t *testing.T, hint string) {
|
||||||
|
t.Helper()
|
||||||
|
if hint == "" {
|
||||||
|
t.Fatalf("hint empty, want external guidance")
|
||||||
|
}
|
||||||
|
for _, blocked := range []string{"auth login", "config --help"} {
|
||||||
|
if strings.Contains(hint, blocked) {
|
||||||
|
t.Fatalf("hint %q must not point at %q (blocked under external provider)", hint, blocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(hint, "external") {
|
||||||
|
t.Fatalf("hint %q should explain credentials are external", hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnose_External_UserReady(t *testing.T) {
|
||||||
|
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice"}
|
||||||
|
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||||
|
|
||||||
|
got := Diagnose(context.Background(), f, cfg, false)
|
||||||
|
// The bug this guards: the built-in path read the keychain (empty under an
|
||||||
|
// external provider) and reported the user as missing. Now availability
|
||||||
|
// follows the resolved account, so a signed-in user reads as ready.
|
||||||
|
if !got.User.Available || got.User.Status != StatusReady || got.User.TokenStatus != StatusReady {
|
||||||
|
t.Fatalf("user = %#v, want ready/available", got.User)
|
||||||
|
}
|
||||||
|
if got.User.OpenID != "ou_x" || got.User.UserName != "Alice" {
|
||||||
|
t.Fatalf("user identity = %#v", got.User)
|
||||||
|
}
|
||||||
|
if got.User.Hint != "" {
|
||||||
|
t.Fatalf("hint = %q, want empty when available", got.User.Hint)
|
||||||
|
}
|
||||||
|
if !got.Bot.Available || got.Bot.Status != StatusReady {
|
||||||
|
t.Fatalf("bot = %#v, want ready/available", got.Bot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnose_External_UserNotSignedIn(t *testing.T) {
|
||||||
|
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll)}
|
||||||
|
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||||
|
|
||||||
|
got := Diagnose(context.Background(), f, cfg, false)
|
||||||
|
if got.User.Available || got.User.Status != StatusMissing {
|
||||||
|
t.Fatalf("user = %#v, want missing/unavailable", got.User)
|
||||||
|
}
|
||||||
|
assertExternalHint(t, got.User.Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnose_External_BotOnly(t *testing.T) {
|
||||||
|
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsBot), UserOpenId: "ou_x"}
|
||||||
|
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||||
|
|
||||||
|
got := Diagnose(context.Background(), f, cfg, false)
|
||||||
|
if !got.Bot.Available || got.Bot.Status != StatusReady {
|
||||||
|
t.Fatalf("bot = %#v, want ready/available", got.Bot)
|
||||||
|
}
|
||||||
|
// Provider declares bot-only: user is unavailable even though an open id is
|
||||||
|
// present, and the hint is external (not "auth login").
|
||||||
|
if got.User.Available || got.User.Status != StatusNotConfigured {
|
||||||
|
t.Fatalf("user = %#v, want not_configured/unavailable", got.User)
|
||||||
|
}
|
||||||
|
assertExternalHint(t, got.User.Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnose_External_UserOnly(t *testing.T) {
|
||||||
|
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandLark, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Bob"}
|
||||||
|
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||||
|
|
||||||
|
got := Diagnose(context.Background(), f, cfg, false)
|
||||||
|
if !got.User.Available || got.User.Status != StatusReady {
|
||||||
|
t.Fatalf("user = %#v, want ready/available", got.User)
|
||||||
|
}
|
||||||
|
if got.Bot.Available || got.Bot.Status != StatusNotConfigured {
|
||||||
|
t.Fatalf("bot = %#v, want not_configured/unavailable", got.Bot)
|
||||||
|
}
|
||||||
|
assertExternalHint(t, got.Bot.Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnose_External_VerifyUserResolvesToken(t *testing.T) {
|
||||||
|
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Alice"}
|
||||||
|
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}, token: &extcred.Token{Value: "ext-uat"}}, cfg)
|
||||||
|
|
||||||
|
got := Diagnose(context.Background(), f, cfg, true)
|
||||||
|
if !got.User.Available || got.User.Verified == nil || !*got.User.Verified {
|
||||||
|
t.Fatalf("user = %#v, want available and verified", got.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnose_External_VerifyUserTokenUnavailable(t *testing.T) {
|
||||||
|
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x"}
|
||||||
|
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||||
|
|
||||||
|
got := Diagnose(context.Background(), f, cfg, true)
|
||||||
|
if got.User.Available || got.User.Status != StatusVerifyFailed {
|
||||||
|
t.Fatalf("user = %#v, want verify_failed/unavailable", got.User)
|
||||||
|
}
|
||||||
|
if got.User.Verified == nil || *got.User.Verified {
|
||||||
|
t.Fatalf("verified = %v, want false", got.User.Verified)
|
||||||
|
}
|
||||||
|
assertExternalHint(t, got.User.Hint)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,30 +5,39 @@ package meta
|
|||||||
|
|
||||||
import "encoding/json"
|
import "encoding/json"
|
||||||
|
|
||||||
// Affordance is the hand-authored usage guidance overlaid on a method: when to
|
// Affordance is the typed usage guidance overlaid on a method. It is the single
|
||||||
// use it, when not to, prerequisites, few-shot examples, and related methods.
|
// model the envelope renderer and the command help both parse, so the
|
||||||
// It is the single typed model of the affordance shape; the envelope renderer
|
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
|
||||||
// and the command help both parse through ParsedAffordance so the vocabulary
|
// Skills entries are skill names (or name/path) rendered as runnable
|
||||||
// is defined once. The JSON tags double as the envelope's wire shape.
|
// `lark-cli skills read <entry>` pointers.
|
||||||
type Affordance struct {
|
type Affordance struct {
|
||||||
UseWhen []string `json:"use_when,omitempty"`
|
UseWhen []string `json:"use_when,omitempty"`
|
||||||
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
|
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||||
Examples []AffordanceCase `json:"examples,omitempty"`
|
Tips []string `json:"tips,omitempty"`
|
||||||
Related []string `json:"related,omitempty"`
|
Examples []AffordanceCase `json:"examples,omitempty"`
|
||||||
|
Extensions []AffordanceSection `json:"extensions,omitempty"`
|
||||||
|
Related []string `json:"related,omitempty"`
|
||||||
|
Skills []string `json:"skills,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AffordanceCase is one few-shot example: a one-line description and a
|
// AffordanceCase is one few-shot example: a description and a ready-to-run command.
|
||||||
// ready-to-run command.
|
|
||||||
type AffordanceCase struct {
|
type AffordanceCase struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description,omitempty"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParsedAffordance decodes the method's raw affordance overlay into the typed
|
// AffordanceSection is a custom guidance section: any heading beyond the
|
||||||
// Affordance. ok is false when the method carries no affordance, the JSON is
|
// standard four (Avoid when / Prerequisites / Tips / Examples) flows through
|
||||||
// malformed, or every section is empty — so callers can treat "no guidance"
|
// here with its label preserved, so authors can add sections without code
|
||||||
// uniformly.
|
// changes.
|
||||||
|
type AffordanceSection struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Items []string `json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedAffordance decodes the method's overlay. ok is false when it is absent,
|
||||||
|
// malformed, or wholly empty — callers treat all three as "no guidance".
|
||||||
func (m Method) ParsedAffordance() (Affordance, bool) {
|
func (m Method) ParsedAffordance() (Affordance, bool) {
|
||||||
if len(m.Affordance) == 0 {
|
if len(m.Affordance) == 0 {
|
||||||
return Affordance{}, false
|
return Affordance{}, false
|
||||||
@@ -37,7 +46,7 @@ func (m Method) ParsedAffordance() (Affordance, bool) {
|
|||||||
if json.Unmarshal(m.Affordance, &a) != nil {
|
if json.Unmarshal(m.Affordance, &a) != nil {
|
||||||
return Affordance{}, false
|
return Affordance{}, false
|
||||||
}
|
}
|
||||||
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
|
if len(a.UseWhen) == 0 && len(a.AvoidWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Tips) == 0 && len(a.Examples) == 0 && len(a.Extensions) == 0 && len(a.Related) == 0 && len(a.Skills) == 0 {
|
||||||
return Affordance{}, false
|
return Affordance{}, false
|
||||||
}
|
}
|
||||||
return a, true
|
return a, true
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestMethod_ParsedAffordance(t *testing.T) {
|
|||||||
notOK := map[string]string{
|
notOK := map[string]string{
|
||||||
"empty payload": ``,
|
"empty payload": ``,
|
||||||
"empty object": `{}`,
|
"empty object": `{}`,
|
||||||
"all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`,
|
"all empty arrays": `{"use_when":[],"avoid_when":[],"prerequisites":[],"tips":[],"examples":[],"related":[]}`,
|
||||||
"malformed string": `"not an object"`,
|
"malformed string": `"not an object"`,
|
||||||
"malformed number": `42`,
|
"malformed number": `42`,
|
||||||
"nested type mismatch": `{"examples":"should be a list"}`,
|
"nested type mismatch": `{"examples":"should be a list"}`,
|
||||||
@@ -35,8 +35,9 @@ func TestMethod_ParsedAffordance(t *testing.T) {
|
|||||||
// Populated affordance parses with all fields.
|
// Populated affordance parses with all fields.
|
||||||
raw := `{
|
raw := `{
|
||||||
"use_when": ["需要拿到当前用户的主日历 ID"],
|
"use_when": ["需要拿到当前用户的主日历 ID"],
|
||||||
"do_not_use_when": ["已知具体 calendar_id"],
|
"avoid_when": ["已知具体 calendar_id"],
|
||||||
"prerequisites": ["user 身份登录"],
|
"prerequisites": ["user 身份登录"],
|
||||||
|
"tips": ["主日历的 calendar_id 即当前用户的 union_id"],
|
||||||
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
|
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
|
||||||
"related": ["calendars.list"]
|
"related": ["calendars.list"]
|
||||||
}`
|
}`
|
||||||
@@ -47,10 +48,22 @@ func TestMethod_ParsedAffordance(t *testing.T) {
|
|||||||
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
|
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
|
||||||
t.Errorf("UseWhen = %v", a.UseWhen)
|
t.Errorf("UseWhen = %v", a.UseWhen)
|
||||||
}
|
}
|
||||||
|
if len(a.Tips) != 1 || a.Tips[0] != "主日历的 calendar_id 即当前用户的 union_id" {
|
||||||
|
t.Errorf("Tips = %v", a.Tips)
|
||||||
|
}
|
||||||
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" {
|
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" {
|
||||||
t.Errorf("Examples = %+v", a.Examples)
|
t.Errorf("Examples = %+v", a.Examples)
|
||||||
}
|
}
|
||||||
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
|
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
|
||||||
t.Errorf("Related = %v", a.Related)
|
t.Errorf("Related = %v", a.Related)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A method whose only guidance is Tips still parses as populated.
|
||||||
|
tipsOnly, ok := (Method{Affordance: json.RawMessage(`{"tips":["先调用 list 拿到 id"]}`)}).ParsedAffordance()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ParsedAffordance with only tips ok=false, want populated")
|
||||||
|
}
|
||||||
|
if len(tipsOnly.Tips) != 1 || tipsOnly.Tips[0] != "先调用 list 拿到 id" {
|
||||||
|
t.Errorf("Tips = %v", tipsOnly.Tips)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
80
internal/output/spinner.go
Normal file
80
internal/output/spinner.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// spinnerFrames are braille spinner glyphs cycled to animate progress.
|
||||||
|
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||||
|
|
||||||
|
const (
|
||||||
|
spinnerInterval = 80 * time.Millisecond
|
||||||
|
spinnerHideCursor = "\x1b[?25l"
|
||||||
|
spinnerShowCursor = "\x1b[?25h"
|
||||||
|
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
|
||||||
|
// until the returned stop() is called, e.g.:
|
||||||
|
//
|
||||||
|
// ⠹ Publishing dev → main... 3s
|
||||||
|
//
|
||||||
|
// It is meant for slow operations (long polls, first-time provisioning) so the
|
||||||
|
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
|
||||||
|
// animation never pollutes stdout — the JSON/pretty result stays clean.
|
||||||
|
//
|
||||||
|
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
|
||||||
|
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
|
||||||
|
// the stderr-TTY check (IOStreams.StderrIsTerminal), not the output format: the
|
||||||
|
// spinner is stderr-only and self-clears, so it is shown in JSON mode too.
|
||||||
|
//
|
||||||
|
// stop() clears the spinner line, restores the cursor, and blocks until the
|
||||||
|
// render goroutine has finished — so callers can safely write the result to
|
||||||
|
// stdout/stderr immediately after. Call stop() BEFORE printing the result, and
|
||||||
|
// it is safe to call more than once (e.g. an explicit call plus a defer).
|
||||||
|
func StartSpinner(w io.Writer, enabled bool, label string) func() {
|
||||||
|
if !enabled || w == nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
finished := make(chan struct{})
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(finished)
|
||||||
|
frame := 0
|
||||||
|
fmt.Fprint(w, spinnerHideCursor)
|
||||||
|
render := func() {
|
||||||
|
elapsed := int(time.Since(start).Seconds())
|
||||||
|
fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed)
|
||||||
|
frame = (frame + 1) % len(spinnerFrames)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
ticker := time.NewTicker(spinnerInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
fmt.Fprint(w, spinnerClearLine+spinnerShowCursor)
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var once sync.Once
|
||||||
|
return func() {
|
||||||
|
once.Do(func() {
|
||||||
|
close(done)
|
||||||
|
<-finished // wait for the line to be cleared before returning
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
54
internal/output/spinner_test.go
Normal file
54
internal/output/spinner_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent.
|
||||||
|
func TestStartSpinner_DisabledIsNoop(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
stop := StartSpinner(&buf, false, "working")
|
||||||
|
stop()
|
||||||
|
stop() // idempotent
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Fatalf("disabled spinner wrote %q, want nothing", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic.
|
||||||
|
func TestStartSpinner_NilWriterIsNoop(t *testing.T) {
|
||||||
|
stop := StartSpinner(nil, true, "working")
|
||||||
|
stop() // must not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop.
|
||||||
|
func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
stop := StartSpinner(&buf, true, "Publishing")
|
||||||
|
// The goroutine renders the first frame synchronously before selecting on
|
||||||
|
// the stop channel, so even an immediate stop() yields one full cycle.
|
||||||
|
stop()
|
||||||
|
stop() // idempotent, must not panic or double-write after finished
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, spinnerHideCursor) {
|
||||||
|
t.Errorf("missing hide-cursor escape:\n%q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, spinnerFrames[0]) {
|
||||||
|
t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Publishing...") {
|
||||||
|
t.Errorf("missing label:\n%q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, spinnerClearLine) {
|
||||||
|
t.Errorf("missing clear-line escape:\n%q", out)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(out, spinnerShowCursor) {
|
||||||
|
t.Errorf("must end by restoring the cursor:\n%q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
92
internal/qualitygate/cmd/comment-audit/main.go
Normal file
92
internal/qualitygate/cmd/comment-audit/main.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||||
|
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type eventPayload struct {
|
||||||
|
Comment *struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
} `json:"comment"`
|
||||||
|
Review *struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
} `json:"review"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
|
||||||
|
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *eventPath == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
body, err := commentBody(*eventPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
diags := diagnostics(publiccontent.ScanComment(*kind, body))
|
||||||
|
if len(diags) > 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
|
||||||
|
}
|
||||||
|
report.Print(os.Stderr, diags)
|
||||||
|
os.Exit(report.ExitCode(diags))
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditFailureSummary(count int) string {
|
||||||
|
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commentBody(path string) (string, error) {
|
||||||
|
safePath, err := validate.SafeInputPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||||
|
WithParam("--event").
|
||||||
|
WithCause(err)
|
||||||
|
}
|
||||||
|
data, err := vfs.ReadFile(safePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var payload eventPayload
|
||||||
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case payload.Comment != nil:
|
||||||
|
return payload.Comment.Body, nil
|
||||||
|
case payload.Review != nil:
|
||||||
|
return payload.Review.Body, nil
|
||||||
|
default:
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnostics(items []publiccontent.Finding) []report.Diagnostic {
|
||||||
|
out := make([]report.Diagnostic, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
out = append(out, report.Diagnostic{
|
||||||
|
Rule: item.Rule,
|
||||||
|
Action: item.Action,
|
||||||
|
File: item.File,
|
||||||
|
Line: item.Line,
|
||||||
|
Message: item.Message,
|
||||||
|
Suggestion: item.Suggestion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
70
internal/qualitygate/cmd/comment-audit/main_test.go
Normal file
70
internal/qualitygate/cmd/comment-audit/main_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"clean comment"}}`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
origDir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chdir(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Chdir(origDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := commentBody("event.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("commentBody() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != "clean comment" {
|
||||||
|
t.Fatalf("comment body = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "event.json")
|
||||||
|
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := commentBody(path)
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if err == nil || !ok {
|
||||||
|
t.Fatalf("commentBody(%q) error = %v, want unsafe path validation error", path, err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("commentBody(%q) problem = %#v, want invalid argument validation", path, problem)
|
||||||
|
}
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) || validationErr.Param != "--event" {
|
||||||
|
t.Fatalf("commentBody(%q) error = %v, want --event validation param", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditFailureSummaryStatesPostPublicationAudit(t *testing.T) {
|
||||||
|
got := auditFailureSummary(2)
|
||||||
|
want := "post-publication audit found public content findings: 2"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("auditFailureSummary() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestFile(path, data string) error {
|
||||||
|
return os.WriteFile(path, []byte(data), 0o644)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||||
"github.com/larksuite/cli/internal/qualitygate/rules"
|
"github.com/larksuite/cli/internal/qualitygate/rules"
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -41,6 +42,7 @@ func runCheck(args []string) int {
|
|||||||
fs.StringVar(&opts.FactsOut, "facts-out", "", "write facts JSON to this path")
|
fs.StringVar(&opts.FactsOut, "facts-out", "", "write facts JSON to this path")
|
||||||
fs.StringVar(&opts.ManifestPath, "manifest", "", "hand-authored command manifest JSON")
|
fs.StringVar(&opts.ManifestPath, "manifest", "", "hand-authored command manifest JSON")
|
||||||
fs.StringVar(&opts.CommandIndexPath, "command-index", "", "full command index JSON")
|
fs.StringVar(&opts.CommandIndexPath, "command-index", "", "full command index JSON")
|
||||||
|
fs.StringVar(&opts.PublicContentMetadataPath, "public-content-metadata", "", "PR title/body metadata JSON for public content checks")
|
||||||
fs.BoolVar(&printLegacyCommandCandidates, "print-legacy-command-candidates", false, "print current non-kebab-case hand-authored command candidates")
|
fs.BoolVar(&printLegacyCommandCandidates, "print-legacy-command-candidates", false, "print current non-kebab-case hand-authored command candidates")
|
||||||
fs.BoolVar(&printLegacyFlagCandidates, "print-legacy-flag-candidates", false, "print current non-kebab-case flag candidates")
|
fs.BoolVar(&printLegacyFlagCandidates, "print-legacy-flag-candidates", false, "print current non-kebab-case flag candidates")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
@@ -48,6 +50,15 @@ func runCheck(args []string) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.PublicContentMetadataPath != "" {
|
||||||
|
safePath, err := validate.SafeInputPath(opts.PublicContentMetadataPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "quality-gate check: --public-content-metadata: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
opts.PublicContentMetadataPath = safePath
|
||||||
|
}
|
||||||
|
|
||||||
if opts.ManifestPath == "" || opts.CommandIndexPath == "" {
|
if opts.ManifestPath == "" || opts.CommandIndexPath == "" {
|
||||||
fmt.Fprintln(os.Stderr, "quality-gate check: --manifest and --command-index are required")
|
fmt.Fprintln(os.Stderr, "quality-gate check: --manifest and --command-index are required")
|
||||||
return 2
|
return 2
|
||||||
|
|||||||
@@ -37,6 +37,37 @@ func TestCheckRequiresManifestInputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckAcceptsPublicContentMetadataFlag(t *testing.T) {
|
||||||
|
code, stderr := runCheckCaptureStderr(t, []string{
|
||||||
|
"--repo", t.TempDir(),
|
||||||
|
"--cli-bin", "./lark-cli",
|
||||||
|
"--public-content-metadata", ".tmp/quality-gate/pr.json",
|
||||||
|
})
|
||||||
|
if code != 2 {
|
||||||
|
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
|
||||||
|
}
|
||||||
|
if strings.Contains(stderr, "flag provided but not defined") {
|
||||||
|
t.Fatalf("public content metadata flag was not registered: %s", stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "--manifest and --command-index are required") {
|
||||||
|
t.Fatalf("stderr = %s", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRejectsUnsafePublicContentMetadataPath(t *testing.T) {
|
||||||
|
code, stderr := runCheckCaptureStderr(t, []string{
|
||||||
|
"--repo", t.TempDir(),
|
||||||
|
"--cli-bin", "./lark-cli",
|
||||||
|
"--public-content-metadata", filepath.Join(t.TempDir(), "pr.json"),
|
||||||
|
})
|
||||||
|
if code != 2 {
|
||||||
|
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "--public-content-metadata") || !strings.Contains(stderr, "--file") {
|
||||||
|
t.Fatalf("stderr = %s, want unsafe public content metadata path error", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckReportsManifestReadErrorsWithFlagName(t *testing.T) {
|
func TestCheckReportsManifestReadErrorsWithFlagName(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
manifestPath := filepath.Join(dir, "command-manifest.json")
|
manifestPath := filepath.Join(dir, "command-manifest.json")
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ func run(args []string) int {
|
|||||||
_ = semantic.WriteMarkdown(markdownOut, decision)
|
_ = semantic.WriteMarkdown(markdownOut, decision)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
if reviewPath == "" && !semantic.BuildInputView(f).HasReviewableFacts() {
|
||||||
|
decision := finalizeDecision(block, waiverDiags, semantic.Decision{})
|
||||||
|
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return decisionExitCode(decision)
|
||||||
|
}
|
||||||
review, err := semantic.LoadOrReviewWithConfig(context.Background(), f, reviewPath, modelConfig)
|
review, err := semantic.LoadOrReviewWithConfig(context.Background(), f, reviewPath, modelConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
|
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
|
||||||
@@ -72,6 +80,15 @@ func run(args []string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
decision := semantic.DecideWithWaivers(f, review, policy, waivers)
|
decision := semantic.DecideWithWaivers(f, review, policy, waivers)
|
||||||
|
decision = finalizeDecision(block, waiverDiags, decision)
|
||||||
|
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return decisionExitCode(decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeDecision(block bool, waiverDiags []report.Diagnostic, decision semantic.Decision) semantic.Decision {
|
||||||
decision.BlockMode = block
|
decision.BlockMode = block
|
||||||
if !block && len(decision.Blockers) > 0 {
|
if !block && len(decision.Blockers) > 0 {
|
||||||
for i := range decision.Blockers {
|
for i := range decision.Blockers {
|
||||||
@@ -81,15 +98,21 @@ func run(args []string) int {
|
|||||||
decision.Blockers = nil
|
decision.Blockers = nil
|
||||||
}
|
}
|
||||||
decision.SystemWarnings = append(diagnosticSystemWarnings(waiverDiags), decision.SystemWarnings...)
|
decision.SystemWarnings = append(diagnosticSystemWarnings(waiverDiags), decision.SystemWarnings...)
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSemanticOutputs(decisionOut, markdownOut string, decision semantic.Decision) error {
|
||||||
if err := semantic.WriteDecision(decisionOut, decision); err != nil {
|
if err := semantic.WriteDecision(decisionOut, decision); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "semantic-review: write decision: %v\n", err)
|
return fmt.Errorf("write decision: %w", err)
|
||||||
return 2
|
|
||||||
}
|
}
|
||||||
if err := semantic.WriteMarkdown(markdownOut, decision); err != nil {
|
if err := semantic.WriteMarkdown(markdownOut, decision); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "semantic-review: write markdown: %v\n", err)
|
return fmt.Errorf("write markdown: %w", err)
|
||||||
return 2
|
|
||||||
}
|
}
|
||||||
if block && len(decision.Blockers) > 0 {
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decisionExitCode(decision semantic.Decision) int {
|
||||||
|
if decision.BlockMode && len(decision.Blockers) > 0 {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||||
@@ -211,7 +212,19 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
|
|||||||
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||||
}`, "")
|
}`, "")
|
||||||
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||||
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
|
f := facts.Facts{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
Skills: []facts.SkillFact{{
|
||||||
|
SourceFile: "skills/lark-wiki/SKILL.md",
|
||||||
|
Line: 30,
|
||||||
|
Changed: true,
|
||||||
|
ReferencesInvalidCommand: true,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if !semantic.BuildInputView(f).HasReviewableFacts() {
|
||||||
|
t.Fatal("test setup must contain reviewable facts")
|
||||||
|
}
|
||||||
|
if err := f.WriteFile(factsPath); err != nil {
|
||||||
t.Fatalf("write facts: %v", err)
|
t.Fatalf("write facts: %v", err)
|
||||||
}
|
}
|
||||||
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||||
@@ -228,6 +241,71 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunShortCircuitsEmptySemanticInputWithoutReviewer(t *testing.T) {
|
||||||
|
t.Setenv("ARK_API_KEY", "")
|
||||||
|
t.Setenv("ARK_BASE_URL", "")
|
||||||
|
t.Setenv("ARK_MODEL", "")
|
||||||
|
|
||||||
|
repo := t.TempDir()
|
||||||
|
writeSemanticConfig(t, repo, `{
|
||||||
|
"schema_version": 1,
|
||||||
|
"default_enforcement": "observe",
|
||||||
|
"block_categories": ["skill_quality"]
|
||||||
|
}`, `{
|
||||||
|
"allowed": ["semantic-review-v1"],
|
||||||
|
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||||
|
}`, "")
|
||||||
|
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||||
|
f := facts.Facts{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
Commands: []facts.CommandFact{{
|
||||||
|
Path: "service command 1",
|
||||||
|
Domain: "service",
|
||||||
|
Changed: true,
|
||||||
|
Source: "service",
|
||||||
|
}},
|
||||||
|
Outputs: []facts.OutputFact{{
|
||||||
|
Command: "service command 1",
|
||||||
|
Domain: "service",
|
||||||
|
Changed: true,
|
||||||
|
Source: "service",
|
||||||
|
IsList: true,
|
||||||
|
HasDefaultLimit: true,
|
||||||
|
HasDecisionField: true,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if semantic.BuildInputView(f).HasReviewableFacts() {
|
||||||
|
t.Fatal("test setup must not contain reviewable facts")
|
||||||
|
}
|
||||||
|
if err := f.WriteFile(factsPath); err != nil {
|
||||||
|
t.Fatalf("write facts: %v", err)
|
||||||
|
}
|
||||||
|
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||||
|
markdownPath := filepath.Join(t.TempDir(), "semantic.md")
|
||||||
|
code := run([]string{"--repo", repo, "--facts", factsPath, "--decision-out", decisionPath, "--markdown-out", markdownPath, "--block"})
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("run() = %d, want clean pass", code)
|
||||||
|
}
|
||||||
|
decision := readDecision(t, decisionPath)
|
||||||
|
if decision.Skipped || decision.Degraded || decision.InfrastructureFailure || !decision.BlockMode {
|
||||||
|
t.Fatalf("expected non-degraded pass decision: %#v", decision)
|
||||||
|
}
|
||||||
|
if len(decision.SystemWarnings) != 0 || len(decision.Warnings) != 0 || len(decision.Blockers) != 0 {
|
||||||
|
t.Fatalf("empty semantic view should not produce findings: %#v", decision)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(markdownPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read markdown: %v", err)
|
||||||
|
}
|
||||||
|
markdown := string(data)
|
||||||
|
if !strings.Contains(markdown, "No semantic blockers.") {
|
||||||
|
t.Fatalf("markdown missing pass summary: %s", markdown)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(markdown), "skipped") || strings.Contains(strings.ToLower(markdown), "degraded") {
|
||||||
|
t.Fatalf("markdown should not report semantic review as skipped/degraded: %s", markdown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testing.T) {
|
func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testing.T) {
|
||||||
t.Setenv("ARK_API_KEY", "test-key")
|
t.Setenv("ARK_API_KEY", "test-key")
|
||||||
t.Setenv("ARK_BASE_URL", "")
|
t.Setenv("ARK_BASE_URL", "")
|
||||||
@@ -243,7 +321,19 @@ func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testi
|
|||||||
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||||
}`, "")
|
}`, "")
|
||||||
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||||
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
|
f := facts.Facts{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
Skills: []facts.SkillFact{{
|
||||||
|
SourceFile: "skills/lark-wiki/SKILL.md",
|
||||||
|
Line: 30,
|
||||||
|
Changed: true,
|
||||||
|
ReferencesInvalidCommand: true,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if !semantic.BuildInputView(f).HasReviewableFacts() {
|
||||||
|
t.Fatal("test setup must contain reviewable facts")
|
||||||
|
}
|
||||||
|
if err := f.WriteFile(factsPath); err != nil {
|
||||||
t.Fatalf("write facts: %v", err)
|
t.Fatalf("write facts: %v", err)
|
||||||
}
|
}
|
||||||
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"error_hint",
|
"error_hint",
|
||||||
"default_output",
|
"default_output",
|
||||||
"naming",
|
"naming",
|
||||||
"skill_quality"
|
"skill_quality",
|
||||||
|
"public_content_leakage"
|
||||||
],
|
],
|
||||||
"rollout_groups": [
|
"rollout_groups": [
|
||||||
{
|
{
|
||||||
@@ -16,7 +17,8 @@
|
|||||||
},
|
},
|
||||||
"categories": [
|
"categories": [
|
||||||
"error_hint",
|
"error_hint",
|
||||||
"skill_quality"
|
"skill_quality",
|
||||||
|
"public_content_leakage"
|
||||||
],
|
],
|
||||||
"owner": "cli-owner",
|
"owner": "cli-owner",
|
||||||
"reason": "first semantic blocking rollout only affects changed facts"
|
"reason": "first semantic blocking rollout only affects changed facts"
|
||||||
|
|||||||
@@ -13,14 +13,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Facts struct {
|
type Facts struct {
|
||||||
SchemaVersion int `json:"schema_version"`
|
SchemaVersion int `json:"schema_version"`
|
||||||
Commands []CommandFact `json:"commands,omitempty"`
|
Commands []CommandFact `json:"commands,omitempty"`
|
||||||
Skills []SkillFact `json:"skills,omitempty"`
|
Skills []SkillFact `json:"skills,omitempty"`
|
||||||
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
|
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
|
||||||
Errors []ErrorFact `json:"errors,omitempty"`
|
Errors []ErrorFact `json:"errors,omitempty"`
|
||||||
Outputs []OutputFact `json:"outputs,omitempty"`
|
Outputs []OutputFact `json:"outputs,omitempty"`
|
||||||
Examples []CommandExample `json:"examples,omitempty"`
|
Examples []CommandExample `json:"examples,omitempty"`
|
||||||
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
|
PublicContent []PublicContentFact `json:"public_content,omitempty"`
|
||||||
|
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommandFact struct {
|
type CommandFact struct {
|
||||||
@@ -109,6 +110,17 @@ type OutputFact struct {
|
|||||||
HasDecisionField bool `json:"has_decision_field,omitempty"`
|
HasDecisionField bool `json:"has_decision_field,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PublicContentFact struct {
|
||||||
|
Rule string `json:"rule"`
|
||||||
|
Action report.Action `json:"action"`
|
||||||
|
File string `json:"file"`
|
||||||
|
Line int `json:"line"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Excerpt string `json:"excerpt,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Suggestion string `json:"suggestion,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type DryRunRequest struct {
|
type DryRunRequest struct {
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@@ -206,6 +218,11 @@ func BuildWithCommandLookup(m manifest.Manifest, commandLookup manifest.Manifest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithPublicContent(f Facts, publicContent []PublicContentFact) Facts {
|
||||||
|
f.PublicContent = publicContent
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
type commandScope struct {
|
type commandScope struct {
|
||||||
Domain string
|
Domain string
|
||||||
Source string
|
Source string
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
|
|||||||
Errors: []ErrorFact{{Code: "invalid_input", Message: "bad path", Hint: "pass --file", Retryable: false, HintActionCount: 1, RequiredHint: true}},
|
Errors: []ErrorFact{{Code: "invalid_input", Message: "bad path", Hint: "pass --file", Retryable: false, HintActionCount: 1, RequiredHint: true}},
|
||||||
Outputs: []OutputFact{{Command: "im messages list", Fields: []string{"message_id", "sender", "create_time"}, IsList: true, HasDefaultLimit: true, HasDecisionField: true}},
|
Outputs: []OutputFact{{Command: "im messages list", Fields: []string{"message_id", "sender", "create_time"}, IsList: true, HasDefaultLimit: true, HasDecisionField: true}},
|
||||||
Skills: []SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 1, DestructiveWithoutGuard: true, ScopeConflict: true}},
|
Skills: []SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 1, DestructiveWithoutGuard: true, ScopeConflict: true}},
|
||||||
|
PublicContent: []PublicContentFact{{Rule: "public_content_generic_credential", Action: report.ActionReject, File: "docs/public.md", Line: 4, Excerpt: "api_key = <redacted>"}},
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(f)
|
data, err := json.Marshal(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -43,7 +44,10 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
|
|||||||
if err := json.Unmarshal(data, &got); err != nil {
|
if err := json.Unmarshal(data, &got); err != nil {
|
||||||
t.Fatalf("unmarshal facts: %v", err)
|
t.Fatalf("unmarshal facts: %v", err)
|
||||||
}
|
}
|
||||||
if !got.Errors[0].RequiredHint || got.Outputs[0].Fields[0] != "message_id" || !got.Skills[0].ScopeConflict {
|
if !got.Errors[0].RequiredHint ||
|
||||||
|
got.Outputs[0].Fields[0] != "message_id" ||
|
||||||
|
!got.Skills[0].ScopeConflict ||
|
||||||
|
got.PublicContent[0].Rule != "public_content_generic_credential" {
|
||||||
t.Fatalf("facts lost gatekeeper fields: %#v", got)
|
t.Fatalf("facts lost gatekeeper fields: %#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
343
internal/qualitygate/publiccontent/collect.go
Normal file
343
internal/qualitygate/publiccontent/collect.go
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package publiccontent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Collect(ctx context.Context, opts Options) ([]Finding, error) {
|
||||||
|
metadata, err := LoadMetadata(opts.MetadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []Finding
|
||||||
|
changedFiles, base, err := changedFiles(ctx, opts.Repo, opts.ChangedFrom)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
patches := map[string][]changedChunk{}
|
||||||
|
if base != "" {
|
||||||
|
patches, err = changedPatches(ctx, opts.Repo, base)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, file := range changedFiles {
|
||||||
|
if !scanChangedFile(file) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, chunk := range patches[file] {
|
||||||
|
findings := scanText(file, "file", chunk.Text, isDetectorRuleFile(file))
|
||||||
|
for i := range findings {
|
||||||
|
findings[i].Line += chunk.StartLine - 1
|
||||||
|
}
|
||||||
|
out = append(out, findings...)
|
||||||
|
out = append(out, semanticCandidate(file, "file", chunk.Text, chunk.StartLine)...)
|
||||||
|
}
|
||||||
|
privateKeyFindings, err := scanTouchedPrivateKeyBlocks(ctx, opts.Repo, file, patches[file])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = appendUniqueFindings(out, privateKeyFindings...)
|
||||||
|
}
|
||||||
|
if base != "" {
|
||||||
|
commitFindings, err := scanCommitMessages(ctx, opts.Repo, base)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, commitFindings...)
|
||||||
|
}
|
||||||
|
branchName := opts.BranchName
|
||||||
|
if branchName == "" {
|
||||||
|
branchName = metadata.Branch
|
||||||
|
}
|
||||||
|
if branchName == "" {
|
||||||
|
branchName = branchFromEnv()
|
||||||
|
}
|
||||||
|
if branchName == "" {
|
||||||
|
branchName = currentBranch(ctx, opts.Repo)
|
||||||
|
}
|
||||||
|
if branchName != "" {
|
||||||
|
out = append(out, scanText("branch", "branch", branchName, false)...)
|
||||||
|
}
|
||||||
|
out = append(out, scanMetadata(metadata)...)
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].File != out[j].File {
|
||||||
|
return out[i].File < out[j].File
|
||||||
|
}
|
||||||
|
if out[i].Line != out[j].Line {
|
||||||
|
return out[i].Line < out[j].Line
|
||||||
|
}
|
||||||
|
return out[i].Rule < out[j].Rule
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentBranch(ctx context.Context, repo string) string {
|
||||||
|
data, err := gitOutput(ctx, repo, "branch", "--show-current")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func branchFromEnv() string {
|
||||||
|
for _, key := range []string{"PR_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME"} {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func changedFiles(ctx context.Context, repo, changedFrom string) ([]string, string, error) {
|
||||||
|
if changedFrom == "" {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
baseBytes, err := gitOutput(ctx, repo, "merge-base", changedFrom, "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
base := strings.TrimSpace(string(baseBytes))
|
||||||
|
files, err := diffFileNames(ctx, repo, base)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
return files, base, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffFileNames(ctx context.Context, repo, base string) ([]string, error) {
|
||||||
|
data, err := gitOutput(ctx, repo, "diff", "--name-only", "-z", "--diff-filter=ACMR", base+"..HEAD")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var files []string
|
||||||
|
for _, file := range bytes.Split(data, []byte{0}) {
|
||||||
|
if len(file) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, filepath.ToSlash(string(file)))
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var detectorFixtureExclusions = map[string]bool{
|
||||||
|
"internal/qualitygate/publiccontent/collect_test.go": true,
|
||||||
|
"internal/qualitygate/publiccontent/rules.go": true,
|
||||||
|
"internal/qualitygate/publiccontent/scan.go": true,
|
||||||
|
"internal/qualitygate/publiccontent/scan_test.go": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanChangedFile(file string) bool {
|
||||||
|
normalized := strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), "./")
|
||||||
|
return !detectorFixtureExclusions[normalized]
|
||||||
|
}
|
||||||
|
|
||||||
|
type changedChunk struct {
|
||||||
|
StartLine int
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c changedChunk) endLine() int {
|
||||||
|
lines := strings.Count(strings.TrimRight(c.Text, "\n"), "\n") + 1
|
||||||
|
if lines < 1 {
|
||||||
|
lines = 1
|
||||||
|
}
|
||||||
|
return c.StartLine + lines - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func changedPatches(ctx context.Context, repo, base string) (map[string][]changedChunk, error) {
|
||||||
|
files, err := diffFileNames(ctx, repo, base)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := gitOutput(ctx, repo, "diff", "--no-ext-diff", "--unified=0", "--diff-filter=ACMR", base+"..HEAD")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string][]changedChunk{}
|
||||||
|
var file string
|
||||||
|
var chunk *changedChunk
|
||||||
|
nextLine := 0
|
||||||
|
nextFile := 0
|
||||||
|
flush := func() {
|
||||||
|
if file == "" || chunk == nil || chunk.Text == "" {
|
||||||
|
chunk = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out[file] = append(out[file], *chunk)
|
||||||
|
chunk = nil
|
||||||
|
}
|
||||||
|
for _, raw := range strings.Split(string(data), "\n") {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(raw, "diff --git "):
|
||||||
|
flush()
|
||||||
|
file = ""
|
||||||
|
if nextFile < len(files) {
|
||||||
|
file = files[nextFile]
|
||||||
|
nextFile++
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(raw, "@@ "):
|
||||||
|
flush()
|
||||||
|
start, ok := parseNewHunkStart(raw)
|
||||||
|
if !ok {
|
||||||
|
nextLine = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextLine = start
|
||||||
|
chunk = &changedChunk{StartLine: start}
|
||||||
|
case strings.HasPrefix(raw, "+") && !strings.HasPrefix(raw, "+++"):
|
||||||
|
if chunk == nil {
|
||||||
|
chunk = &changedChunk{StartLine: max(nextLine, 1)}
|
||||||
|
}
|
||||||
|
chunk.Text += strings.TrimPrefix(raw, "+") + "\n"
|
||||||
|
nextLine++
|
||||||
|
case strings.HasPrefix(raw, "-"):
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
if chunk != nil && strings.HasPrefix(raw, `\ No newline at end of file`) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNewHunkStart(header string) (int, bool) {
|
||||||
|
parts := strings.Split(header, " ")
|
||||||
|
for _, part := range parts {
|
||||||
|
if !strings.HasPrefix(part, "+") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw := strings.TrimPrefix(part, "+")
|
||||||
|
if before, _, ok := strings.Cut(raw, ","); ok {
|
||||||
|
raw = before
|
||||||
|
}
|
||||||
|
start, err := strconv.Atoi(raw)
|
||||||
|
return start, err == nil && start > 0
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCommitMessages(ctx context.Context, repo, base string) ([]Finding, error) {
|
||||||
|
data, err := gitOutput(ctx, repo, "log", "--format=%H%x00%B%x00", base+"..HEAD")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parts := bytes.Split(data, []byte{0})
|
||||||
|
var out []Finding
|
||||||
|
for i := 0; i+1 < len(parts); i += 2 {
|
||||||
|
sha := strings.TrimSpace(string(parts[i]))
|
||||||
|
body := string(parts[i+1])
|
||||||
|
if sha == "" || body == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
short := sha
|
||||||
|
if len(short) > 12 {
|
||||||
|
short = short[:12]
|
||||||
|
}
|
||||||
|
out = append(out, scanText("commit:"+short, "commit", body, false)...)
|
||||||
|
out = append(out, semanticCandidate("commit:"+short, "commit", body, 1)...)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type lineRange struct {
|
||||||
|
Start int
|
||||||
|
End int
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanTouchedPrivateKeyBlocks(ctx context.Context, repo, file string, chunks []changedChunk) ([]Finding, error) {
|
||||||
|
if len(chunks) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
data, err := gitOutput(ctx, repo, "show", "HEAD:"+file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var added []lineRange
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
added = append(added, lineRange{Start: chunk.StartLine, End: chunk.endLine()})
|
||||||
|
}
|
||||||
|
var out []Finding
|
||||||
|
for _, block := range privateKeyBlocks(string(data)) {
|
||||||
|
if !rangesIntersectAny(block, added) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, newFinding("public_content_private_key_block", file, block.Start, "file", "private key block"))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func privateKeyBlocks(text string) []lineRange {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
var out []lineRange
|
||||||
|
inPrivateKey := false
|
||||||
|
start := 0
|
||||||
|
for i, line := range lines {
|
||||||
|
lineNo := i + 1
|
||||||
|
if !inPrivateKey && strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||||
|
inPrivateKey = true
|
||||||
|
start = lineNo
|
||||||
|
}
|
||||||
|
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||||
|
out = append(out, lineRange{Start: start, End: lineNo})
|
||||||
|
inPrivateKey = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func rangesIntersectAny(block lineRange, ranges []lineRange) bool {
|
||||||
|
for _, r := range ranges {
|
||||||
|
if block.Start <= r.End && r.Start <= block.End {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUniqueFindings(items []Finding, additions ...Finding) []Finding {
|
||||||
|
for _, addition := range additions {
|
||||||
|
duplicate := false
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Rule == addition.Rule &&
|
||||||
|
item.File == addition.File &&
|
||||||
|
item.Line == addition.Line &&
|
||||||
|
item.Source == addition.Source {
|
||||||
|
duplicate = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !duplicate {
|
||||||
|
items = append(items, addition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitOutput(ctx context.Context, repo string, args ...string) ([]byte, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", args...)
|
||||||
|
cmd.Dir = repo
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, stderr.Bytes())
|
||||||
|
}
|
||||||
|
return stdout.Bytes(), nil
|
||||||
|
}
|
||||||
885
internal/qualitygate/publiccontent/collect_test.go
Normal file
885
internal/qualitygate/publiccontent/collect_test.go
Normal file
@@ -0,0 +1,885 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package publiccontent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||||
|
repo := t.TempDir()
|
||||||
|
runGit(t, repo, "init")
|
||||||
|
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||||
|
runGit(t, repo, "config", "user.name", "Test User")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "baseline.md"), `BASE_`+`TOKEN="baseline-only"
|
||||||
|
`)
|
||||||
|
runGit(t, repo, "add", "baseline.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
|
||||||
|
|
||||||
|
api_`+`key = "example-public-key"
|
||||||
|
`)
|
||||||
|
runGit(t, repo, "add", "docs/public.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||||
|
writeFile(t, metadataPath, `{"title":"publish public docs","body":"Reviewed`+`-on: https://review.example.test/c/project/+/123"}`)
|
||||||
|
|
||||||
|
got, err := Collect(context.Background(), Options{
|
||||||
|
Repo: repo,
|
||||||
|
ChangedFrom: "HEAD~1",
|
||||||
|
MetadataPath: metadataPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Collect() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rules := findingRules(got)
|
||||||
|
for _, want := range []string{
|
||||||
|
"public_content_generic_credential",
|
||||||
|
"public_content_change_id_trailer",
|
||||||
|
"public_content_reviewed_on_trailer",
|
||||||
|
} {
|
||||||
|
if !rules[want] {
|
||||||
|
t.Fatalf("missing rule %s in findings %#v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File == "baseline.md" {
|
||||||
|
t.Fatalf("collector scanned unchanged baseline file: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
|
||||||
|
repo := t.TempDir()
|
||||||
|
runGit(t, repo, "init")
|
||||||
|
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||||
|
runGit(t, repo, "config", "user.name", "Test User")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\n")
|
||||||
|
runGit(t, repo, "add", "docs/workflow.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\nnew public line\n")
|
||||||
|
runGit(t, repo, "add", "docs/workflow.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "add public line")
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||||
|
writeFile(t, metadataPath, `{}`)
|
||||||
|
|
||||||
|
got, err := Collect(context.Background(), Options{
|
||||||
|
Repo: repo,
|
||||||
|
ChangedFrom: "HEAD~1",
|
||||||
|
MetadataPath: metadataPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Collect() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, item := range got {
|
||||||
|
if item.Rule == "public_content_generic_credential" && item.File == "docs/workflow.md" {
|
||||||
|
t.Fatalf("collector scanned unchanged legacy line in changed file: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectSemanticCandidatesStoreSanitizedReviewText(t *testing.T) {
|
||||||
|
repo := t.TempDir()
|
||||||
|
runGit(t, repo, "init")
|
||||||
|
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||||
|
runGit(t, repo, "config", "user.name", "Test User")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
|
||||||
|
runGit(t, repo, "add", "docs/public.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
raw := "private launch plan for alpha-service rollout on Friday with SERVICE_" + "TOKEN=real-" + "secret-value"
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
|
||||||
|
runGit(t, repo, "add", "docs/public.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "add semantic candidate")
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||||
|
writeFile(t, metadataPath, `{}`)
|
||||||
|
|
||||||
|
got, err := Collect(context.Background(), Options{
|
||||||
|
Repo: repo,
|
||||||
|
ChangedFrom: "HEAD~1",
|
||||||
|
MetadataPath: metadataPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Collect() error = %v", err)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, item := range got {
|
||||||
|
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if !strings.Contains(item.Excerpt, "alpha-service rollout on Friday") {
|
||||||
|
t.Fatalf("semantic candidate should include sanitized review text, got %#v", item)
|
||||||
|
}
|
||||||
|
if strings.Contains(item.Excerpt, "real-"+"secret-value") {
|
||||||
|
t.Fatalf("semantic candidate leaked credential value: %#v", item)
|
||||||
|
}
|
||||||
|
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
|
||||||
|
t.Fatalf("semantic candidate should redact credentials in review text, got %#v", item)
|
||||||
|
}
|
||||||
|
if !strings.Contains(item.Excerpt, "semantic signals") || !strings.Contains(item.Excerpt, "roadmap_timing") {
|
||||||
|
t.Fatalf("semantic candidate excerpt should preserve semantic signals, got %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("missing semantic candidate in findings %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectSemanticCandidatesDoNotLeakWhitespaceCredentialTail(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
|
||||||
|
runGit(t, repo, "add", "docs/public.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
raw := "private launch plan for internal rollout on Friday with SERVICE_" + "TOKEN=\"real " + "secret value\""
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
|
||||||
|
runGit(t, repo, "add", "docs/public.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "add semantic candidate")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
for _, item := range got {
|
||||||
|
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(item.Excerpt, "secret value") || strings.Contains(item.Excerpt, "real "+"secret value") {
|
||||||
|
t.Fatalf("semantic candidate leaked credential tail: %#v", item)
|
||||||
|
}
|
||||||
|
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
|
||||||
|
t.Fatalf("semantic candidate should redact full credential assignment, got %#v", item)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("missing semantic candidate in findings %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectJSONBearerHeadersDoNotLeakIntoSemanticCandidates(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
|
||||||
|
runGit(t, repo, "add", "docs/public.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
token := "abcdefghijklmnopqrstuvwxyz"
|
||||||
|
raw := "private launch plan for internal rollout on Friday with " +
|
||||||
|
`{"headers":{"Authorization":"Bearer ` + token + `"}}`
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
|
||||||
|
runGit(t, repo, "add", "docs/public.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "add json bearer")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
requireFinding(t, got, "docs/public.md", "public_content_bearer_header")
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File != "docs/public.md" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(item.Excerpt, token) {
|
||||||
|
t.Fatalf("finding leaked JSON bearer token: %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
|
||||||
|
runGit(t, repo, "add", "docs/public.json")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||||
|
`{"access_` + `token":"real-json-token"}`,
|
||||||
|
`{"client_` + `secret": "real ` + `secret value"}`,
|
||||||
|
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
|
||||||
|
`{"github` + `Token":"real-github-token"}`,
|
||||||
|
`{"vendorApi` + `Key":"real-vendor-key"}`,
|
||||||
|
`{"slackBot` + `Token":"xoxb-real-token"}`,
|
||||||
|
}, "\n")+"\n")
|
||||||
|
runGit(t, repo, "add", "docs/public.json")
|
||||||
|
runGit(t, repo, "commit", "-m", "add json config")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
var count int
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||||
|
count++
|
||||||
|
for _, forbidden := range []string{
|
||||||
|
"real-json-token",
|
||||||
|
"real secret value",
|
||||||
|
"real-tenant-camel-token",
|
||||||
|
"real-github-token",
|
||||||
|
"real-vendor-key",
|
||||||
|
"xoxb-real-token",
|
||||||
|
} {
|
||||||
|
if strings.Contains(item.Excerpt, forbidden) {
|
||||||
|
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 6 {
|
||||||
|
t.Fatalf("JSON credential findings = %d, want 6: %#v", count, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectAllowsBenignJSONTokenFields(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
|
||||||
|
runGit(t, repo, "add", "docs/public.json")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||||
|
`{"tokenizer":"cl100k_base"}`,
|
||||||
|
`{"token_count": 42}`,
|
||||||
|
`{"page_token":"next"}`,
|
||||||
|
`{"next_page_token":"next"}`,
|
||||||
|
`{"file_token":"file-example"}`,
|
||||||
|
`{"doc_token":"doc-example"}`,
|
||||||
|
`{"node_token":"node-example"}`,
|
||||||
|
`{"wiki_token":"wikcn_public_doc_example"}`,
|
||||||
|
`{"folder_token":"folder-example"}`,
|
||||||
|
`{"obj_token":"obj-example"}`,
|
||||||
|
`{"spreadsheet_token":"sheet-example"}`,
|
||||||
|
`{"parent_node_token":"parent-example"}`,
|
||||||
|
`{"origin_node_token":"origin-example"}`,
|
||||||
|
`{"drive_route_token":"route-example"}`,
|
||||||
|
`{"token":"<wiki_token>"}`,
|
||||||
|
`{"token":"wiki_token"}`,
|
||||||
|
`{"token_url":"https://example.com/oauth/token"}`,
|
||||||
|
`{"token_endpoint":"https://example.com/oauth/token"}`,
|
||||||
|
`{"token_format":"Bearer"}`,
|
||||||
|
`{"secret_name":"public-example-secret"}`,
|
||||||
|
`{"base_token":"base-example"}`,
|
||||||
|
`{"app_token":"app-example"}`,
|
||||||
|
`{"sync_token":"sync-example"}`,
|
||||||
|
`{"parent_token":"parent-example"}`,
|
||||||
|
`{"target_token":"target-example"}`,
|
||||||
|
`{"parent_file_token":"parent-file-example"}`,
|
||||||
|
`{"refresh_token_expires_in": 7200}`,
|
||||||
|
`{"access_token_expires_in": 7200}`,
|
||||||
|
`{"token_expires_in": 7200}`,
|
||||||
|
`{"token_status":"active"}`,
|
||||||
|
}, "\n")+"\n")
|
||||||
|
runGit(t, repo, "add", "docs/public.json")
|
||||||
|
runGit(t, repo, "commit", "-m", "add benign json token fields")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||||
|
t.Fatalf("benign JSON token field should not be credential finding: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||||
|
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||||
|
"API_KEY: <" + stripeLike + ">",
|
||||||
|
"SECRET_TOKEN: <" + patLike + ">",
|
||||||
|
"CLIENT_SECRET: <real-client-secret-value>",
|
||||||
|
}, "\n")+"\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "add credential config")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
var count int
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 3 {
|
||||||
|
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
|
||||||
|
runGit(t, repo, "add", "docs/public.json")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||||
|
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||||
|
`{"access_token_expires_in":"` + patLike + `"}`,
|
||||||
|
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
|
||||||
|
`{"client_secret_status":"real-client-secret-value"}`,
|
||||||
|
`{"client_secret_name":"real-client-secret-value"}`,
|
||||||
|
`{"app_token":"` + patLike + `"}`,
|
||||||
|
`{"sync_token":"` + stripeLike + `"}`,
|
||||||
|
`{"target_token":"real-client-secret-value"}`,
|
||||||
|
}, "\n")+"\n")
|
||||||
|
runGit(t, repo, "add", "docs/public.json")
|
||||||
|
runGit(t, repo, "commit", "-m", "add credential-shaped benign fields")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
var count int
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 7 {
|
||||||
|
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||||
|
"API_KEY_NAME: prod_key",
|
||||||
|
"CLIENT_SECRET_NAME: prod_secret",
|
||||||
|
"SECRET_STATUS: prod_secret",
|
||||||
|
}, "\n")+"\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "add credential config")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
var count int
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 3 {
|
||||||
|
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||||
|
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||||
|
"ACCESS_KEY_ID: " + accessKey,
|
||||||
|
"ACCESS_KEY: " + accessKey,
|
||||||
|
}, "\n")+"\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "add access key config")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
var count int
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
|
||||||
|
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 3 {
|
||||||
|
t.Fatalf("access key credential findings = %d, want 3: %#v", count, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
privateKey := "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t"
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||||
|
"PRIVATE_KEY: " + privateKey,
|
||||||
|
"SSH_PRIVATE_KEY: " + privateKey,
|
||||||
|
"JWT_PRIVATE_KEY: " + privateKey,
|
||||||
|
"SIGNING_PRIVATE_KEY: " + privateKey,
|
||||||
|
}, "\n")+"\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "add private key config")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
var count int
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if strings.Contains(item.Excerpt, privateKey) {
|
||||||
|
t.Fatalf("private key finding leaked value in excerpt %q", item.Excerpt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 4 {
|
||||||
|
t.Fatalf("private key assignment findings = %d, want 4: %#v", count, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||||
|
"API_KEY_OPENAI: prod_key",
|
||||||
|
"CLIENT_SECRET_GOOGLE: prod_secret",
|
||||||
|
"TOKEN_GITHUB: github_token",
|
||||||
|
"APP_PASSWORD_PROD: prod_password",
|
||||||
|
}, "\n")+"\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "add credential config")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
var count int
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 4 {
|
||||||
|
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||||
|
"tokens: 128",
|
||||||
|
"token_type: bearer",
|
||||||
|
"max_tokens: 2000",
|
||||||
|
"completion_tokens: 200",
|
||||||
|
"prompt_tokens: 100",
|
||||||
|
}, "\n")+"\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "add benign token config")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||||
|
t.Fatalf("benign unquoted token field should not be credential finding: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||||
|
"API_KEY_OPENAI: real-openai-key",
|
||||||
|
"TOKEN_GITHUB: real-github-token",
|
||||||
|
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||||
|
"SECRET_KEY_BASE: real-secret-key-base",
|
||||||
|
"APP_PASSWORD_PROD: real-prod-password",
|
||||||
|
}, "\n")+"\n")
|
||||||
|
runGit(t, repo, "add", "docs/config.yaml")
|
||||||
|
runGit(t, repo, "commit", "-m", "add credential config")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
var count int
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
for _, forbidden := range []string{
|
||||||
|
"real-openai-key",
|
||||||
|
"real-github-token",
|
||||||
|
"real-google-secret",
|
||||||
|
"real-secret-key-base",
|
||||||
|
"real-prod-password",
|
||||||
|
} {
|
||||||
|
if strings.Contains(item.Excerpt, forbidden) {
|
||||||
|
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 5 {
|
||||||
|
t.Fatalf("credential suffix variants findings = %d, want 5: %#v", count, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectDetectsPrivateKeyWhenOnlyEndIsAdded(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n")
|
||||||
|
runGit(t, repo, "add", "docs/key.pem")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\nnew-body\n"+privateKeyEnd())
|
||||||
|
runGit(t, repo, "add", "docs/key.pem")
|
||||||
|
runGit(t, repo, "commit", "-m", "complete key")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectDetectsPrivateKeyWhenOnlyBeginIsAdded(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "key.pem"), "legacy-body\n"+privateKeyEnd())
|
||||||
|
runGit(t, repo, "add", "docs/key.pem")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||||
|
runGit(t, repo, "add", "docs/key.pem")
|
||||||
|
runGit(t, repo, "commit", "-m", "complete key")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectDetectsPrivateKeyWhenOnlyBodyIsAdded(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
|
||||||
|
runGit(t, repo, "add", "docs/key.pem")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"new-body\n"+privateKeyEnd())
|
||||||
|
runGit(t, repo, "add", "docs/key.pem")
|
||||||
|
runGit(t, repo, "commit", "-m", "add body")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectIgnoresUntouchedHistoricalPrivateKey(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||||
|
runGit(t, repo, "add", "docs/key.pem")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "public.md"), "public docs update\n")
|
||||||
|
runGit(t, repo, "add", "docs/public.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "docs update")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
|
||||||
|
t.Fatalf("collector reported untouched historical private key: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectIgnoresDeletedPrivateKeyLine(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||||
|
runGit(t, repo, "add", "docs/key.pem")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
|
||||||
|
runGit(t, repo, "add", "docs/key.pem")
|
||||||
|
runGit(t, repo, "commit", "-m", "remove body")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
|
||||||
|
t.Fatalf("collector reported delete-only private key cleanup: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
|
||||||
|
repo := t.TempDir()
|
||||||
|
runGit(t, repo, "init")
|
||||||
|
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||||
|
runGit(t, repo, "config", "user.name", "Test User")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
|
||||||
|
runGit(t, repo, "add", "README.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "collect_test.go"), "SECRET_TOKEN=fixture\n")
|
||||||
|
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
|
||||||
|
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
|
||||||
|
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
|
||||||
|
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
|
||||||
|
runGit(t, repo, "add", ".")
|
||||||
|
runGit(t, repo, "commit", "-m", "add scanner fixtures")
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||||
|
writeFile(t, metadataPath, `{}`)
|
||||||
|
|
||||||
|
got, err := Collect(context.Background(), Options{
|
||||||
|
Repo: repo,
|
||||||
|
ChangedFrom: "HEAD~1",
|
||||||
|
MetadataPath: metadataPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Collect() error = %v", err)
|
||||||
|
}
|
||||||
|
var foundOrdinaryTestLeak bool
|
||||||
|
for _, item := range got {
|
||||||
|
switch item.File {
|
||||||
|
case "internal/qualitygate/publiccontent/collect_test.go",
|
||||||
|
"internal/qualitygate/publiccontent/scan.go",
|
||||||
|
"internal/qualitygate/publiccontent/scan_test.go",
|
||||||
|
"internal/qualitygate/publiccontent/rules.go":
|
||||||
|
t.Fatalf("collector scanned known fixture or detector implementation file: %#v", got)
|
||||||
|
}
|
||||||
|
if item.File == "tests/e2e/new-public-workflow.test.sh" && item.Rule == "public_content_generic_credential" {
|
||||||
|
foundOrdinaryTestLeak = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundOrdinaryTestLeak {
|
||||||
|
t.Fatalf("collector should still scan ordinary test files for real leaks: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanChangedFileDocumentsFixtureExclusions(t *testing.T) {
|
||||||
|
excluded := []string{
|
||||||
|
"internal/qualitygate/publiccontent/collect_test.go",
|
||||||
|
"internal/qualitygate/publiccontent/rules.go",
|
||||||
|
"internal/qualitygate/publiccontent/scan.go",
|
||||||
|
"internal/qualitygate/publiccontent/scan_test.go",
|
||||||
|
}
|
||||||
|
for _, file := range excluded {
|
||||||
|
if scanChangedFile(file) {
|
||||||
|
t.Fatalf("scanChangedFile(%q) = true, want false for detector fixture/implementation path", file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
included := []string{
|
||||||
|
"internal/qualitygate/publiccontent/new_test.go",
|
||||||
|
"tests/e2e/new-public-workflow.test.sh",
|
||||||
|
"docs/public.md",
|
||||||
|
}
|
||||||
|
for _, file := range included {
|
||||||
|
if !scanChangedFile(file) {
|
||||||
|
t.Fatalf("scanChangedFile(%q) = false, want true", file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "old.md"), "base\n")
|
||||||
|
runGit(t, repo, "add", ".")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
|
||||||
|
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
|
||||||
|
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
|
||||||
|
runGit(t, repo, "add", ".")
|
||||||
|
runGit(t, repo, "commit", "-m", "add special paths")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
requireFinding(t, got, "docs/has space.md", "public_content_generic_credential")
|
||||||
|
requireFinding(t, got, `weird"quote.md`, "public_content_generic_credential")
|
||||||
|
requireFinding(t, got, "docs/new name.md", "public_content_generic_credential")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectScansBranchNameAsWarning(t *testing.T) {
|
||||||
|
repo := t.TempDir()
|
||||||
|
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||||
|
writeFile(t, metadataPath, `{"branch":"bot/public-doc-update"}`)
|
||||||
|
got, err := Collect(context.Background(), Options{
|
||||||
|
Repo: repo,
|
||||||
|
MetadataPath: metadataPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Collect() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].Rule != "public_content_automation_branch" {
|
||||||
|
t.Fatalf("branch findings = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectUsesExplicitBranchNameWhenDetached(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
|
||||||
|
runGit(t, repo, "add", "README.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
runGit(t, repo, "checkout", "-b", "bot/public-doc-update")
|
||||||
|
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
|
||||||
|
runGit(t, repo, "add", "docs.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "docs")
|
||||||
|
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
|
||||||
|
runGit(t, repo, "checkout", "--detach", head)
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||||
|
writeFile(t, metadataPath, `{}`)
|
||||||
|
got, err := Collect(context.Background(), Options{
|
||||||
|
Repo: repo,
|
||||||
|
MetadataPath: metadataPath,
|
||||||
|
BranchName: "bot/public-doc-update",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Collect() error = %v", err)
|
||||||
|
}
|
||||||
|
requireFinding(t, got, "branch", "public_content_automation_branch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectUsesBranchEnvironmentWhenDetached(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
|
||||||
|
runGit(t, repo, "add", "README.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
runGit(t, repo, "checkout", "-b", "bot/public-env-update")
|
||||||
|
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
|
||||||
|
runGit(t, repo, "add", "docs.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "docs")
|
||||||
|
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
|
||||||
|
runGit(t, repo, "checkout", "--detach", head)
|
||||||
|
t.Setenv("GITHUB_HEAD_REF", "bot/public-env-update")
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||||
|
writeFile(t, metadataPath, `{}`)
|
||||||
|
got, err := Collect(context.Background(), Options{
|
||||||
|
Repo: repo,
|
||||||
|
MetadataPath: metadataPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Collect() error = %v", err)
|
||||||
|
}
|
||||||
|
requireFinding(t, got, "branch", "public_content_automation_branch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectPreservesFindingAttributionForChangedLines(t *testing.T) {
|
||||||
|
repo := newGitRepo(t)
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\n")
|
||||||
|
runGit(t, repo, "add", "docs/auth.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\nAuthorization: Bearer abcdefghijklmnopqrstuvwxyz\n")
|
||||||
|
runGit(t, repo, "add", "docs/auth.md")
|
||||||
|
runGit(t, repo, "commit", "-m", "add auth docs")
|
||||||
|
|
||||||
|
got := collectFromPreviousCommit(t, repo)
|
||||||
|
for _, item := range got {
|
||||||
|
if item.Rule == "public_content_bearer_header" {
|
||||||
|
if item.File != "docs/auth.md" || item.Line != 2 || item.Source != "file" {
|
||||||
|
t.Fatalf("changed-line attribution = %#v", item)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing bearer finding: %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendUniqueFindingsDeduplicatesByRuleFileLineAndSource(t *testing.T) {
|
||||||
|
base := []Finding{newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block")}
|
||||||
|
got := appendUniqueFindings(base,
|
||||||
|
newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block"),
|
||||||
|
newFinding("public_content_private_key_block", "docs/key.pem", 2, "file", "private key block"),
|
||||||
|
)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("appendUniqueFindings len = %d, want 2: %#v", len(got), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGitRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
repo := t.TempDir()
|
||||||
|
runGit(t, repo, "init")
|
||||||
|
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||||
|
runGit(t, repo, "config", "user.name", "Test User")
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func privateKeyBegin() string {
|
||||||
|
return privateKeyBeginPrefix + privateKeyMarker + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func privateKeyEnd() string {
|
||||||
|
return privateKeyEndPrefix + privateKeyMarker + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectFromPreviousCommit(t *testing.T, repo string) []Finding {
|
||||||
|
t.Helper()
|
||||||
|
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||||
|
writeFile(t, metadataPath, `{}`)
|
||||||
|
got, err := Collect(context.Background(), Options{
|
||||||
|
Repo: repo,
|
||||||
|
ChangedFrom: "HEAD~1",
|
||||||
|
MetadataPath: metadataPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Collect() error = %v", err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireFinding(t *testing.T, got []Finding, file, rule string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File == file && item.Rule == rule {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing %s in %s findings: %#v", rule, file, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectRequiresValidMetadataJSON(t *testing.T) {
|
||||||
|
repo := t.TempDir()
|
||||||
|
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||||
|
writeFile(t, metadataPath, `{"title":`)
|
||||||
|
|
||||||
|
_, err := Collect(context.Background(), Options{Repo: repo, MetadataPath: metadataPath})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "public content metadata") {
|
||||||
|
t.Fatalf("Collect() error = %v, want metadata parse error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGit(t *testing.T, repo string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
if len(args) > 0 && args[0] == "commit" {
|
||||||
|
args = append([]string{"commit", "--no-verify"}, args[1:]...)
|
||||||
|
}
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = repo
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = repo
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, data string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
internal/qualitygate/publiccontent/comment_audit.go
Normal file
11
internal/qualitygate/publiccontent/comment_audit.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package publiccontent
|
||||||
|
|
||||||
|
func ScanComment(kind, body string) []Finding {
|
||||||
|
if kind == "" {
|
||||||
|
kind = "comment"
|
||||||
|
}
|
||||||
|
return scanText(kind, "comment", body, false)
|
||||||
|
}
|
||||||
19
internal/qualitygate/publiccontent/comment_audit_test.go
Normal file
19
internal/qualitygate/publiccontent/comment_audit_test.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package publiccontent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||||
|
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
|
||||||
|
rules := findingRules(got)
|
||||||
|
if !rules["public_content_harness_metadata"] || !rules["public_content_ccm_harness_trailer"] {
|
||||||
|
t.Fatalf("comment audit findings = %#v", got)
|
||||||
|
}
|
||||||
|
for _, item := range got {
|
||||||
|
if item.File != "issue_comment" {
|
||||||
|
t.Fatalf("comment finding file = %q, want issue_comment", item.File)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
internal/qualitygate/publiccontent/metadata.go
Normal file
45
internal/qualitygate/publiccontent/metadata.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package publiccontent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LoadMetadata(path string) (Metadata, error) {
|
||||||
|
if path == "" {
|
||||||
|
return Metadata{}, nil
|
||||||
|
}
|
||||||
|
data, err := vfs.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return Metadata{}, nil
|
||||||
|
}
|
||||||
|
var out Metadata
|
||||||
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanMetadata(m Metadata) []Finding {
|
||||||
|
text := ""
|
||||||
|
if m.Title != "" {
|
||||||
|
text += "title: " + m.Title + "\n"
|
||||||
|
}
|
||||||
|
if m.Body != "" {
|
||||||
|
text += "body:\n" + m.Body + "\n"
|
||||||
|
}
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := scanText("pull_request_metadata", "metadata", text, false)
|
||||||
|
out = append(out, semanticCandidate("pull_request_metadata", "metadata", text, 1)...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
22
internal/qualitygate/publiccontent/metadata_test.go
Normal file
22
internal/qualitygate/publiccontent/metadata_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package publiccontent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadMetadataReadsTitleAndBody(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "metadata.json")
|
||||||
|
writeFile(t, path, `{"title":"public change","body":"pass`+`word = \"example-password\""}`)
|
||||||
|
|
||||||
|
got, err := LoadMetadata(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMetadata() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.Title != "public change" || got.Body == "" {
|
||||||
|
t.Fatalf("metadata = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
509
internal/qualitygate/publiccontent/rules.go
Normal file
509
internal/qualitygate/publiccontent/rules.go
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package publiccontent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
|
||||||
|
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
|
||||||
|
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
|
||||||
|
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
|
||||||
|
semanticBearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+[^"'\s,}\]]+|["']Authorization["']\s*:\s*["']Bearer\s+[^"'\\\s,}\]]+)`)
|
||||||
|
changeIDTrailerRE = regexp.MustCompile(`(?i)^\s*Change-Id:\s*\S+`)
|
||||||
|
reviewedOnTrailerRE = regexp.MustCompile(`(?i)^\s*Reviewed-on:\s*\S+`)
|
||||||
|
ccmHarnessTrailerRE = regexp.MustCompile(`(?i)\bCCM-Harness:\s*\S+`)
|
||||||
|
privateIPv4RE = regexp.MustCompile(`\b(?:10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|172\.(?:1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3})\b`)
|
||||||
|
automationBranchRE = regexp.MustCompile(`(?i)(^|/)(bot|automation)[-/]`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func actionForRule(rule string) report.Action {
|
||||||
|
switch rule {
|
||||||
|
case "public_content_generic_credential",
|
||||||
|
"public_content_private_key_block",
|
||||||
|
"public_content_jwt_like_token",
|
||||||
|
"public_content_bearer_header",
|
||||||
|
"public_content_credential_url",
|
||||||
|
"public_content_change_id_trailer",
|
||||||
|
"public_content_reviewed_on_trailer",
|
||||||
|
"public_content_provenance_marker",
|
||||||
|
"public_content_detector_fingerprint",
|
||||||
|
"public_content_harness_metadata",
|
||||||
|
"public_content_ccm_harness_trailer":
|
||||||
|
return report.ActionReject
|
||||||
|
case "public_content_private_ipv4",
|
||||||
|
"public_content_automation_branch":
|
||||||
|
return report.ActionWarning
|
||||||
|
default:
|
||||||
|
return report.ActionWarning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPlaceholderValue(value string) bool {
|
||||||
|
trimmed := strings.Trim(value, `"'`)
|
||||||
|
normalized := strings.ToLower(trimmed)
|
||||||
|
if normalized == "" ||
|
||||||
|
normalized == "=" ||
|
||||||
|
printfPlaceholderValue(normalized) ||
|
||||||
|
htmlEntityAnglePlaceholder(normalized) ||
|
||||||
|
starMaskedPlaceholder(normalized) ||
|
||||||
|
percentWrappedPlaceholder(normalized) ||
|
||||||
|
angleWrappedPlaceholder(normalized) ||
|
||||||
|
urlWithAnglePlaceholder(normalized) ||
|
||||||
|
isCredentialReferenceValue(trimmed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return namedPlaceholderValue(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func htmlEntityAnglePlaceholder(value string) bool {
|
||||||
|
if !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return anglePlaceholderIdentifier(strings.TrimSuffix(strings.TrimPrefix(value, "<"), ">"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func starMaskedPlaceholder(value string) bool {
|
||||||
|
var stars int
|
||||||
|
for _, r := range value {
|
||||||
|
if r == '*' {
|
||||||
|
stars++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return stars >= 3
|
||||||
|
}
|
||||||
|
|
||||||
|
func namedPlaceholderValue(value string) bool {
|
||||||
|
switch value {
|
||||||
|
case "...", "***", "****", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret", "test-token", "dry-run", "dry_run":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(value, "cli_example") ||
|
||||||
|
allXPlaceholder(value) ||
|
||||||
|
conventionalNamedPlaceholderValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printfPlaceholderValue(value string) bool {
|
||||||
|
switch value {
|
||||||
|
case "%d", "%s", "%q", "%v", "%w", "%x", "%T":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allXPlaceholder(value string) bool {
|
||||||
|
if len(value) < 4 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if r != 'x' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func conventionalNamedPlaceholderValue(value string) bool {
|
||||||
|
if !delimitedPlaceholderIdentifier(value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
normalized := strings.ReplaceAll(value, "-", "_")
|
||||||
|
if rest, ok := strings.CutPrefix(normalized, "your_"); ok {
|
||||||
|
return conventionalCredentialPlaceholderName(rest)
|
||||||
|
}
|
||||||
|
if rest, ok := strings.CutSuffix(normalized, "_here"); ok {
|
||||||
|
return conventionalCredentialPlaceholderName(rest)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func conventionalCredentialPlaceholderName(value string) bool {
|
||||||
|
switch value {
|
||||||
|
case "api_key",
|
||||||
|
"access_key",
|
||||||
|
"private_key",
|
||||||
|
"secret",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"token",
|
||||||
|
"webhook",
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
"bearer_token",
|
||||||
|
"session_token",
|
||||||
|
"client_secret":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlWithAnglePlaceholder(value string) bool {
|
||||||
|
if !strings.Contains(value, "://") ||
|
||||||
|
!strings.Contains(value, "<") ||
|
||||||
|
!strings.Contains(value, ">") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !urlRemainderLooksCredentialLike(removeAnglePlaceholders(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAnglePlaceholders(value string) string {
|
||||||
|
var out strings.Builder
|
||||||
|
for len(value) > 0 {
|
||||||
|
start := strings.Index(value, "<")
|
||||||
|
if start < 0 {
|
||||||
|
out.WriteString(value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out.WriteString(value[:start])
|
||||||
|
end := strings.Index(value[start+1:], ">")
|
||||||
|
if end < 0 {
|
||||||
|
out.WriteString(value[start:])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value = value[start+end+2:]
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlRemainderLooksCredentialLike(value string) bool {
|
||||||
|
normalized := strings.ToLower(value)
|
||||||
|
for _, marker := range []string{
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"private_key",
|
||||||
|
"privatekey",
|
||||||
|
"client_secret",
|
||||||
|
"clientsecret",
|
||||||
|
} {
|
||||||
|
if strings.Contains(normalized, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, part := range strings.FieldsFunc(normalized, func(r rune) bool {
|
||||||
|
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
|
||||||
|
}) {
|
||||||
|
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func longCredentialSegment(value string) bool {
|
||||||
|
if len(value) < 16 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var hasLetter, hasDigit bool
|
||||||
|
for _, r := range value {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
hasLetter = true
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
hasDigit = true
|
||||||
|
case r == '_' || r == '-':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasLetter || hasDigit
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCredentialReferenceValue(value string) bool {
|
||||||
|
normalized := strings.ToLower(value)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(normalized, "${{"):
|
||||||
|
return githubExpressionReference(normalized)
|
||||||
|
case strings.HasPrefix(normalized, "$("):
|
||||||
|
return !commandSubstitutionLooksCredentialLike(normalized)
|
||||||
|
case strings.HasPrefix(normalized, "process.env."):
|
||||||
|
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "process.env."))
|
||||||
|
case strings.HasPrefix(normalized, "${"):
|
||||||
|
return credentialReferenceIdentifier(strings.TrimSuffix(strings.TrimPrefix(normalized, "${"), "}"))
|
||||||
|
case strings.HasPrefix(value, "$"):
|
||||||
|
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "$"))
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandSubstitutionLooksCredentialLike(value string) bool {
|
||||||
|
if !strings.HasPrefix(value, "$(") || !strings.HasSuffix(value, ")") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
inner := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")")
|
||||||
|
for _, part := range strings.FieldsFunc(inner, func(r rune) bool {
|
||||||
|
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
|
||||||
|
}) {
|
||||||
|
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func githubExpressionReference(value string) bool {
|
||||||
|
if !strings.HasPrefix(value, "${{") || !strings.HasSuffix(value, "}}") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(expr, "secrets."):
|
||||||
|
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "secrets."))
|
||||||
|
case strings.HasPrefix(expr, "env."):
|
||||||
|
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "env."))
|
||||||
|
case strings.HasPrefix(expr, "vars."):
|
||||||
|
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "vars."))
|
||||||
|
case expr == "github.token":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dottedReferenceIdentifier(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, part := range strings.Split(value, ".") {
|
||||||
|
if !referenceIdentifier(part) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func credentialReferenceIdentifier(value string) bool {
|
||||||
|
return referenceIdentifier(value) && !credentialShapedIdentifier(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func referenceIdentifier(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, r := range value {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
case r >= '0' && r <= '9' && i > 0:
|
||||||
|
case r == '_' && i > 0:
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func angleWrappedPlaceholder(value string) bool {
|
||||||
|
if len(value) < 3 || !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return anglePlaceholderIdentifier(strings.Trim(value, "<>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentWrappedPlaceholder(value string) bool {
|
||||||
|
if len(value) < 3 || !strings.HasPrefix(value, "%") || !strings.HasSuffix(value, "%") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
inner := strings.Trim(value, "%")
|
||||||
|
return delimitedPlaceholderIdentifier(inner) && !credentialShapedIdentifier(inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delimitedPlaceholderIdentifier(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func anglePlaceholderIdentifier(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if credentialShapedIdentifier(value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch value {
|
||||||
|
case "token",
|
||||||
|
"id",
|
||||||
|
"userid",
|
||||||
|
"openid",
|
||||||
|
"key",
|
||||||
|
"secret",
|
||||||
|
"password",
|
||||||
|
"api-key",
|
||||||
|
"user-id",
|
||||||
|
"open-id",
|
||||||
|
"client-secret",
|
||||||
|
"access-token",
|
||||||
|
"refresh-token",
|
||||||
|
"auth-token",
|
||||||
|
"bearer-token",
|
||||||
|
"session-token",
|
||||||
|
"service-token":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, suffix := range []string{"_token", "_id", "_key", "_secret", "_password"} {
|
||||||
|
if strings.HasSuffix(value, suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, suffix := range []string{"-token", "-id", "-key", "-secret", "-password"} {
|
||||||
|
if strings.HasSuffix(value, suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func credentialShapedValue(value string) bool {
|
||||||
|
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||||
|
return credentialShapedIdentifier(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func credentialShapedIdentifier(value string) bool {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(value, "sk_live_"),
|
||||||
|
strings.HasPrefix(value, "sk_test_"),
|
||||||
|
strings.HasPrefix(value, "ghp_"),
|
||||||
|
strings.HasPrefix(value, "gho_"),
|
||||||
|
strings.HasPrefix(value, "ghu_"),
|
||||||
|
strings.HasPrefix(value, "github_pat_"),
|
||||||
|
strings.HasPrefix(value, "xoxb_"),
|
||||||
|
strings.HasPrefix(value, "xoxp_"),
|
||||||
|
strings.HasPrefix(value, "xoxa_"):
|
||||||
|
return true
|
||||||
|
case strings.HasPrefix(value, "real-") &&
|
||||||
|
(strings.Contains(value, "secret") ||
|
||||||
|
strings.Contains(value, "token") ||
|
||||||
|
strings.Contains(value, "key") ||
|
||||||
|
strings.Contains(value, "password")):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourceTokenPlaceholderValue(value string) bool {
|
||||||
|
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||||
|
switch normalized {
|
||||||
|
case "wiki_token",
|
||||||
|
"folder_token",
|
||||||
|
"obj_token",
|
||||||
|
"spreadsheet_token",
|
||||||
|
"file_token",
|
||||||
|
"doc_token",
|
||||||
|
"node_token",
|
||||||
|
"parent_node_token",
|
||||||
|
"origin_node_token",
|
||||||
|
"drive_route_token":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return minuteTokenFixturePlaceholder(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func minuteTokenFixturePlaceholder(value string) bool {
|
||||||
|
if value == "minute_no_meta" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
suffix, ok := strings.CutPrefix(value, "minute_")
|
||||||
|
if !ok || suffix == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range suffix {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func provenanceMarker(line string) bool {
|
||||||
|
normalized := strings.ToLower(line)
|
||||||
|
markers := []string{
|
||||||
|
"generat" + "ed by tool",
|
||||||
|
"creat" + "ed by tool",
|
||||||
|
"generat" + "ed by automation",
|
||||||
|
"creat" + "ed by automation",
|
||||||
|
"machine-" + "generated",
|
||||||
|
"generated with automated",
|
||||||
|
"generated with automation",
|
||||||
|
"🤖 generated",
|
||||||
|
}
|
||||||
|
for _, marker := range markers {
|
||||||
|
if strings.Contains(normalized, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(normalized, "co-authored-by:") &&
|
||||||
|
(strings.Contains(normalized, "<bot@") ||
|
||||||
|
strings.Contains(normalized, " bot@") ||
|
||||||
|
strings.Contains(normalized, "[bot]") ||
|
||||||
|
strings.Contains(normalized, "automation") ||
|
||||||
|
strings.Contains(normalized, "automated-code-assistant")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detector fingerprint checks are intentionally scoped to public rule/config
|
||||||
|
// files. They do not try to hide this package's implementation; they prevent
|
||||||
|
// publishing reusable detector identifiers in external-facing rule bundles.
|
||||||
|
func isDetectorRuleFile(path string) bool {
|
||||||
|
normalized := filepath.ToSlash(path)
|
||||||
|
base := filepath.Base(normalized)
|
||||||
|
return base == ".gitleaks.toml" ||
|
||||||
|
strings.Contains(normalized, "public-rules/") ||
|
||||||
|
strings.Contains(normalized, "public_rules/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectorFingerprint(line string) bool {
|
||||||
|
normalized := strings.ToLower(line)
|
||||||
|
fingerprints := []string{
|
||||||
|
strings.Join([]string{"public", "content", "leakage"}, "-"),
|
||||||
|
strings.Join([]string{"public", "content", "detector"}, "-"),
|
||||||
|
"publiccontent",
|
||||||
|
}
|
||||||
|
for _, fingerprint := range fingerprints {
|
||||||
|
if strings.Contains(normalized, fingerprint) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactCredentialURL(raw string) string {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.User == nil {
|
||||||
|
return "<credential-url>"
|
||||||
|
}
|
||||||
|
u.User = url.UserPassword("<user>", "<redacted>")
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
1233
internal/qualitygate/publiccontent/scan.go
Normal file
1233
internal/qualitygate/publiccontent/scan.go
Normal file
File diff suppressed because it is too large
Load Diff
1429
internal/qualitygate/publiccontent/scan_test.go
Normal file
1429
internal/qualitygate/publiccontent/scan_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user