mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 02:54:04 +08:00
Compare commits
33 Commits
feat/lark-
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efa636f6b3 | ||
|
|
519a600b62 | ||
|
|
d87d9b458a | ||
|
|
1173179b10 | ||
|
|
74d8458635 | ||
|
|
80fadf1801 | ||
|
|
c04da4723a | ||
|
|
a09388d035 | ||
|
|
cdd9d3409b | ||
|
|
06f6b0b18c | ||
|
|
9413e7cd8b | ||
|
|
047d729f72 | ||
|
|
1a9f637866 | ||
|
|
34c4ba5581 | ||
|
|
9a6ba41684 | ||
|
|
f495cbb166 | ||
|
|
6f95c5eb22 | ||
|
|
4e2cbea94e | ||
|
|
f98dbfe247 | ||
|
|
40ea4d60ef | ||
|
|
f0b6f35fee | ||
|
|
91d785f92f | ||
|
|
e621c6e50f | ||
|
|
869a259d4e | ||
|
|
ee46e22abd | ||
|
|
b76dc18c2f | ||
|
|
85679d4258 | ||
|
|
1ba4f3973c | ||
|
|
c45ff569c4 | ||
|
|
a1506cdffb | ||
|
|
3595356ea1 | ||
|
|
73be1d06ec | ||
|
|
cccf025599 |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -263,13 +263,19 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
python-version: '3.x'
|
||||||
|
- name: Resolve CLI E2E domains
|
||||||
|
id: e2e_domains
|
||||||
|
run: node scripts/e2e_domains.js
|
||||||
- name: Build lark-cli
|
- name: Build lark-cli
|
||||||
|
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
run: make build
|
run: make build
|
||||||
- name: Run dry-run E2E tests
|
- name: Run dry-run E2E tests
|
||||||
env:
|
env:
|
||||||
@@ -277,7 +283,28 @@ jobs:
|
|||||||
LARKSUITE_CLI_APP_ID: dry-run
|
LARKSUITE_CLI_APP_ID: dry-run
|
||||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||||
LARKSUITE_CLI_BRAND: feishu
|
LARKSUITE_CLI_BRAND: feishu
|
||||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||||
|
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||||
|
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
||||||
|
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
||||||
|
run: |
|
||||||
|
if [ "$E2E_MODE" = "skip" ]; then
|
||||||
|
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
||||||
|
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||||
|
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
||||||
|
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
||||||
|
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
||||||
|
fi
|
||||||
|
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
||||||
|
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
||||||
|
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
||||||
|
fi
|
||||||
|
|
||||||
e2e-live:
|
e2e-live:
|
||||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||||
@@ -292,15 +319,22 @@ jobs:
|
|||||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
python-version: '3.x'
|
||||||
|
- name: Resolve CLI E2E domains
|
||||||
|
id: e2e_domains
|
||||||
|
run: node scripts/e2e_domains.js
|
||||||
- name: Build lark-cli
|
- name: Build lark-cli
|
||||||
|
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
run: make build
|
run: make build
|
||||||
- name: Configure bot credentials
|
- name: Configure bot credentials
|
||||||
|
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||||
@@ -310,16 +344,24 @@ jobs:
|
|||||||
- name: Run CLI E2E tests
|
- name: Run CLI E2E tests
|
||||||
env:
|
env:
|
||||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||||
|
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||||
|
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||||
|
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||||
run: |
|
run: |
|
||||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
if [ "$E2E_MODE" = "skip" ]; then
|
||||||
|
echo "No live CLI E2E needed: $E2E_REASON"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
packages="$E2E_LIVE_PACKAGES"
|
||||||
if [ -z "$packages" ]; then
|
if [ -z "$packages" ]; then
|
||||||
echo "No CLI E2E packages to test after exclusions."
|
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
echo "Live CLI E2E packages: $packages"
|
||||||
|
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||||
- name: Publish CLI E2E test report
|
- name: Publish CLI E2E test report
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||||
with:
|
with:
|
||||||
name: CLI E2E Tests
|
name: CLI E2E Tests
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,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/
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
69
CHANGELOG.md
69
CHANGELOG.md
@@ -2,6 +2,72 @@
|
|||||||
|
|
||||||
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.67] - 2026-07-08
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **mail**: add message modify and trash shortcuts (#1567)
|
||||||
|
- support whiteboard file inputs in docs XML (#1784)
|
||||||
|
- **vc**: refine meeting-events output and reaction forwarding (#1674)
|
||||||
|
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- accept opaque wiki node tokens (#1789)
|
||||||
|
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
|
||||||
|
- preserve original filename in multipart file upload (#1767)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- restore one-time authorization guidance in lark-apps skill (#1794)
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
|
||||||
|
|
||||||
|
## [v1.0.66] - 2026-07-07
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- support semantic recurring calendar operations (#1723)
|
||||||
|
- minute wait (#1768)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- guide drive import concurrency conflicts (#1751)
|
||||||
|
- **calendar**: guide approval room booking fallback (#1637)
|
||||||
|
- support pnpm global installs in self-update (#1705)
|
||||||
|
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- tighten doc creation validation workflow (#1759)
|
||||||
|
- clarify success envelope contract — judge success by ok, not code (#1730)
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- **envvars**: consolidate agent env value access (#1757)
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
|
||||||
|
|
||||||
|
## [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
|
## [v1.0.64] - 2026-07-02
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -1355,6 +1421,9 @@ 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.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
|
||||||
|
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||||
|
[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.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.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.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
|||||||
bash scripts/resolve-changed-from.test.sh
|
bash scripts/resolve-changed-from.test.sh
|
||||||
bash scripts/ci-workflow.test.sh
|
bash scripts/ci-workflow.test.sh
|
||||||
bash scripts/semantic-review-workflow.test.sh
|
bash scripts/semantic-review-workflow.test.sh
|
||||||
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||||
|
|
||||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||||
unit-test: fetch_meta
|
unit-test: fetch_meta
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -233,6 +233,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
|||||||
--format csv # Comma-separated values
|
--format csv # Comma-separated values
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### JSON Output Contract
|
||||||
|
|
||||||
|
With `--format json` (the default), success and error envelopes are distinct.
|
||||||
|
|
||||||
|
Success goes to **stdout**, exit code `0`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors go to **stderr**, non-zero exit code:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
|
||||||
|
|
||||||
### Pagination
|
### Pagination
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
18
README.zh.md
18
README.zh.md
@@ -234,6 +234,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
|||||||
--format csv # 逗号分隔值
|
--format csv # 逗号分隔值
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### JSON 输出契约
|
||||||
|
|
||||||
|
`--format json`(默认)下,成功与错误的信封结构不同。
|
||||||
|
|
||||||
|
成功信封写入 **stdout**,退出码 0:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
错误信封写入 **stderr**,退出码非 0:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code` 和 `msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
|
||||||
|
|
||||||
### 分页
|
### 分页
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -10,18 +10,33 @@ step. Maintain these files alongside `skills/` and `shortcuts/`.
|
|||||||
A small, fixed markdown subset; each file describes one domain:
|
A small, fixed markdown subset; each file describes one domain:
|
||||||
|
|
||||||
# <domain> optional `> skill: <name>` applies to every command below
|
# <domain> optional `> skill: <name>` applies to every command below
|
||||||
## <command> the command as typed, minus `lark-cli <domain>`
|
## <command> the command as typed, minus `lark-cli <domain>`; a
|
||||||
|
+-prefixed heading (## +create) targets that shortcut
|
||||||
<lead paragraph> when to use this command
|
<lead paragraph> when to use this command
|
||||||
### Avoid when when not to use it / which command to use instead
|
### 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)
|
### Prerequisites what you must have first (e.g. an id, and where it comes from)
|
||||||
### Tips gotchas and constraints
|
### Tips gotchas and constraints
|
||||||
### Examples **description** lines, each followed by a fenced command
|
### Examples **description** lines, each followed by a fenced command
|
||||||
|
### Skills bullet skill names, or name/relpath references
|
||||||
|
(lark-contact/references/x.md), to read for usage;
|
||||||
|
merged with the domain `> skill:` default (deduped,
|
||||||
|
domain first)
|
||||||
### <other heading> a custom section; flows through verbatim
|
### <other heading> a custom section; flows through verbatim
|
||||||
|
|
||||||
Reference another command with `[[command]]` — it renders as `command` in help.
|
Reference another command with `[[command]]` — it renders as `command` in help.
|
||||||
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
||||||
("… from [[command]]") it means "get the input there first".
|
("… from [[command]]") it means "get the input there first".
|
||||||
|
|
||||||
|
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
|
||||||
|
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
|
||||||
|
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
|
||||||
|
(validated against the path); help drops any that don't resolve, so a typo shows
|
||||||
|
nothing. Point a command at its own reference (e.g. `+search-user` →
|
||||||
|
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
|
||||||
|
domain skill, which the `> skill:` default already covers. When a shortcut also
|
||||||
|
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
|
||||||
|
replace the Go tips (not merged), so keep tips in one place.
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
## messages get
|
## messages get
|
||||||
@@ -47,3 +62,5 @@ Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
|||||||
anything the schema and flags already show; the agent infers the rest.
|
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
|
- Command-form headings resolve to method ids via the registry, so plural resource
|
||||||
names (`messages`) map to the singular method id (`message`) automatically.
|
names (`messages`) map to the singular method id (`message`) automatically.
|
||||||
|
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
|
||||||
|
so the heading must equal the shortcut command exactly (`## +history-revert`).
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
# contact
|
# contact
|
||||||
> skill: lark-contact
|
> skill: lark-contact
|
||||||
|
|
||||||
|
## +search-user
|
||||||
|
The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups.
|
||||||
|
|
||||||
|
### Skills
|
||||||
|
- lark-contact/references/lark-contact-search-user.md
|
||||||
|
|
||||||
|
### Avoid when
|
||||||
|
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
|
||||||
|
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Find a user by name**
|
||||||
|
```bash
|
||||||
|
lark-cli contact +search-user --query "alice" --as user
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fetch known users by open_id (me = yourself)**
|
||||||
|
```bash
|
||||||
|
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
|
||||||
|
```
|
||||||
|
|
||||||
|
## +get-user
|
||||||
|
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
|
||||||
|
|
||||||
|
### Skills
|
||||||
|
- lark-contact/references/lark-contact-get-user.md
|
||||||
|
|
||||||
|
### Avoid when
|
||||||
|
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
|
||||||
|
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
|
||||||
|
- --user-id-type must match the id you pass (default open_id)
|
||||||
|
|
||||||
## user_profiles batch_query
|
## user_profiles batch_query
|
||||||
Bulk-fetch personal status and signature for user ids you already have.
|
Bulk-fetch personal status and signature for user ids you already have.
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,14 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -20,13 +24,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 +73,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 +96,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 +117,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 +144,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 +157,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 +170,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 +236,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 +255,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 +274,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 +291,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 +316,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 +347,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 +387,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 +428,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 +467,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 +502,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 +568,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 +619,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 +675,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 +740,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 +760,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 +779,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 +810,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 +831,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 +849,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 +878,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 +899,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 +918,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 +936,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 +953,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 +970,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 +993,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 +1034,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 +1060,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
|
||||||
})
|
})
|
||||||
@@ -1054,3 +1073,157 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
|||||||
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseMultipartFilenames drives one api --file upload through the mock
|
||||||
|
// transport and returns a map of field name -> part filename parsed from the
|
||||||
|
// captured multipart body, plus the map of text form fields. It fails the test
|
||||||
|
// if the captured request is not multipart/form-data.
|
||||||
|
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]string, map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
ct := stub.CapturedHeaders.Get("Content-Type")
|
||||||
|
mediaType, params, err := mime.ParseMediaType(ct)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse Content-Type %q: %v", ct, err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(mediaType, "multipart/") {
|
||||||
|
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
|
||||||
|
}
|
||||||
|
filenames := map[string]string{}
|
||||||
|
fields := map[string]string{}
|
||||||
|
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if fn := part.FileName(); fn != "" {
|
||||||
|
filenames[part.FormName()] = fn
|
||||||
|
} else {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
_, _ = buf.ReadFrom(part)
|
||||||
|
fields[part.FormName()] = buf.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filenames, fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
cmdutil.TestChdir(t, dir)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||||
|
t.Fatalf("write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stub := &httpmock.Stub{
|
||||||
|
URL: "/open-apis/approval/v4/files/upload",
|
||||||
|
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||||
|
}
|
||||||
|
reg.Register(stub)
|
||||||
|
|
||||||
|
cmd := NewCmdApi(f, nil)
|
||||||
|
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "invoice.pdf"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filenames, _ := parseMultipartFilenames(t, stub)
|
||||||
|
if got := filenames["file"]; got != "invoice.pdf" {
|
||||||
|
t.Fatalf("part filename for field %q = %q, want %q", "file", got, "invoice.pdf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
cmdutil.TestChdir(t, dir)
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0700); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "sub", "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||||
|
t.Fatalf("write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stub := &httpmock.Stub{
|
||||||
|
URL: "/open-apis/approval/v4/files/upload",
|
||||||
|
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||||
|
}
|
||||||
|
reg.Register(stub)
|
||||||
|
|
||||||
|
cmd := NewCmdApi(f, nil)
|
||||||
|
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "upload=sub/invoice.pdf"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filenames, _ := parseMultipartFilenames(t, stub)
|
||||||
|
if _, ok := filenames["upload"]; !ok {
|
||||||
|
t.Fatalf("expected field name %q from field=path form, got fields %v", "upload", filenames)
|
||||||
|
}
|
||||||
|
if got := filenames["upload"]; got != "invoice.pdf" {
|
||||||
|
t.Fatalf("part filename for field %q = %q, want %q (basename only)", "upload", got, "invoice.pdf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
cmdutil.TestChdir(t, dir)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||||
|
t.Fatalf("write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stub := &httpmock.Stub{
|
||||||
|
URL: "/open-apis/approval/v4/files/upload",
|
||||||
|
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||||
|
}
|
||||||
|
reg.Register(stub)
|
||||||
|
|
||||||
|
cmd := NewCmdApi(f, nil)
|
||||||
|
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot",
|
||||||
|
"--file", "invoice.pdf", "--data", `{"type":"attachment"}`})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filenames, fields := parseMultipartFilenames(t, stub)
|
||||||
|
if got := filenames["file"]; got != "invoice.pdf" {
|
||||||
|
t.Fatalf("part filename = %q, want %q", got, "invoice.pdf")
|
||||||
|
}
|
||||||
|
if got := fields["type"]; got != "attachment" {
|
||||||
|
t.Fatalf("text field type = %q, want %q", got, "attachment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))
|
||||||
|
|
||||||
|
stub := &httpmock.Stub{
|
||||||
|
URL: "/open-apis/approval/v4/files/upload",
|
||||||
|
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||||
|
}
|
||||||
|
reg.Register(stub)
|
||||||
|
|
||||||
|
cmd := NewCmdApi(f, nil)
|
||||||
|
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "-"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filenames, _ := parseMultipartFilenames(t, stub)
|
||||||
|
if got := filenames["file"]; got != "unknown-file" {
|
||||||
|
t.Fatalf("stdin part filename = %q, want %q (no stable local name, fallback)", got, "unknown-file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
92
cmd/build.go
92
cmd/build.go
@@ -21,12 +21,16 @@ import (
|
|||||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||||
"github.com/larksuite/cli/cmd/whoami"
|
"github.com/larksuite/cli/cmd/whoami"
|
||||||
_ "github.com/larksuite/cli/events"
|
_ "github.com/larksuite/cli/events"
|
||||||
|
"github.com/larksuite/cli/extension/platform"
|
||||||
"github.com/larksuite/cli/internal/apicatalog"
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/hook"
|
"github.com/larksuite/cli/internal/hook"
|
||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
|
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||||
|
"github.com/larksuite/cli/internal/policystate"
|
||||||
|
"github.com/larksuite/cli/internal/skillpolicy"
|
||||||
"github.com/larksuite/cli/shortcuts"
|
"github.com/larksuite/cli/shortcuts"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -42,6 +46,7 @@ type buildConfig struct {
|
|||||||
skipStrictMode bool
|
skipStrictMode bool
|
||||||
skipService bool
|
skipService bool
|
||||||
serviceCatalog *apicatalog.Catalog
|
serviceCatalog *apicatalog.Catalog
|
||||||
|
skillsOverlay *platform.SkillsOverlay
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
||||||
@@ -59,6 +64,17 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithEmbeddedSkills customizes the CLI's embedded skills for a caller that builds
|
||||||
|
// the command tree directly instead of registering a plugin. It is the
|
||||||
|
// build-option analogue of a plugin's EmbeddedSkills(...): the same single-owner
|
||||||
|
// rule applies, so combining WithEmbeddedSkills with a plugin that also customizes
|
||||||
|
// skills aborts startup. nil is a no-op.
|
||||||
|
func WithEmbeddedSkills(spec *platform.SkillsOverlay) BuildOption {
|
||||||
|
return func(c *buildConfig) {
|
||||||
|
c.skillsOverlay = spec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
||||||
// at build time. It is registered by the repo-root package main's init via
|
// at build time. It is registered by the repo-root package main's init via
|
||||||
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
||||||
@@ -71,6 +87,21 @@ var embeddedSkillContent fs.FS
|
|||||||
// supply its own skill content.
|
// supply its own skill content.
|
||||||
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||||
|
|
||||||
|
// withEmbeddedSkillsOwner labels a WithEmbeddedSkills contribution in the skill resolver so a
|
||||||
|
// WithEmbeddedSkills + plugin-Skills collision names a stable, non-plugin owner.
|
||||||
|
const withEmbeddedSkillsOwner = "cmd.WithEmbeddedSkills"
|
||||||
|
|
||||||
|
// resolveSkillContent composes the effective embedded skill tree from the CLI
|
||||||
|
// default, an optional WithEmbeddedSkills spec, and plugin SkillsOverlays, enforcing the
|
||||||
|
// single-owner rule across all sources.
|
||||||
|
func resolveSkillContent(cfg *buildConfig, pluginSkills []skillpolicy.PluginSkill) (fs.FS, error) {
|
||||||
|
sources := pluginSkills
|
||||||
|
if cfg.skillsOverlay != nil {
|
||||||
|
sources = append([]skillpolicy.PluginSkill{{PluginName: withEmbeddedSkillsOwner, SkillsOverlay: cfg.skillsOverlay}}, sources...)
|
||||||
|
}
|
||||||
|
return skillpolicy.Resolve(embeddedSkillContent, sources)
|
||||||
|
}
|
||||||
|
|
||||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||||
// When hide is true the flag stays registered (so existing invocations still
|
// When hide is true the flag stays registered (so existing invocations still
|
||||||
// parse) but is omitted from help and shell completion. Typically called as
|
// parse) but is omitted from help and shell completion. Typically called as
|
||||||
@@ -154,6 +185,14 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
cfg.streams = cmdutil.SystemIO()
|
cfg.streams = cmdutil.SystemIO()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset every process-global snapshot up front, not only inside
|
||||||
|
// applyUserPolicyPruning: the skipPlugins and install-failure paths
|
||||||
|
// return before pruning runs, and a previous build's state must not
|
||||||
|
// leak into this one (long-lived embedders, test sequences).
|
||||||
|
policystate.SetPluginDeniedDomains(nil)
|
||||||
|
cmdpolicy.SetActive(nil)
|
||||||
|
internalplatform.SetActiveInventory(nil)
|
||||||
|
|
||||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||||
if cfg.keychain != nil {
|
if cfg.keychain != nil {
|
||||||
f.Keychain = cfg.keychain
|
f.Keychain = cfg.keychain
|
||||||
@@ -175,7 +214,16 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
// rootUsageTemplate.
|
// rootUsageTemplate.
|
||||||
rootCmd.SetUsageTemplate(rootUsageTemplate)
|
rootCmd.SetUsageTemplate(rootUsageTemplate)
|
||||||
|
|
||||||
installTipsHelpFunc(rootCmd)
|
// The skill-content getter also gates on the skills command domain:
|
||||||
|
// every pointer it feeds renders as `lark-cli skills read ...`, so with
|
||||||
|
// that domain plugin-denied the pointers would all be dead ends even
|
||||||
|
// though the content itself still exists.
|
||||||
|
installTipsHelpFunc(rootCmd, func() fs.FS {
|
||||||
|
if policystate.DomainDeniedByPlugin("skills") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return f.SkillContent
|
||||||
|
})
|
||||||
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
|
||||||
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
|
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
|
||||||
@@ -223,6 +271,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.skipPlugins {
|
if cfg.skipPlugins {
|
||||||
|
installHelpCommand(rootCmd)
|
||||||
|
resolved, err := resolveSkillContent(cfg, nil)
|
||||||
|
if err != nil {
|
||||||
|
installPluginSkillErrorGuard(rootCmd, err)
|
||||||
|
return f, rootCmd, nil
|
||||||
|
}
|
||||||
|
f.SkillContent = resolved
|
||||||
recordInventory(nil)
|
recordInventory(nil)
|
||||||
return f, rootCmd, nil
|
return f, rootCmd, nil
|
||||||
}
|
}
|
||||||
@@ -233,23 +288,52 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
return f, rootCmd, nil
|
return f, rootCmd, nil
|
||||||
}
|
}
|
||||||
var pluginRules []cmdpolicy.PluginRule
|
var pluginRules []cmdpolicy.PluginRule
|
||||||
|
var pluginSkills []skillpolicy.PluginSkill
|
||||||
var registry *hook.Registry
|
var registry *hook.Registry
|
||||||
if installResult != nil {
|
if installResult != nil {
|
||||||
pluginRules = installResult.PluginRules
|
pluginRules = installResult.PluginRules
|
||||||
|
pluginSkills = installResult.PluginSkills
|
||||||
registry = installResult.Registry
|
registry = installResult.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Policy errors fail-CLOSED when a plugin contributed (security
|
// Policy errors fail-CLOSED when a plugin contributed (security
|
||||||
// intent must not be silently dropped); yaml-only errors fail-OPEN
|
// intent must not be silently dropped); yaml-only errors fail-OPEN
|
||||||
// with a warning so a typo can't lock the user out.
|
// with a warning so a typo can't lock the user out.
|
||||||
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
|
denied, policyErr := applyUserPolicyPruning(rootCmd, pluginRules)
|
||||||
|
if policyErr != nil {
|
||||||
if len(pluginRules) > 0 {
|
if len(pluginRules) > 0 {
|
||||||
installPluginConflictGuard(rootCmd, err)
|
installPluginConflictGuard(rootCmd, policyErr)
|
||||||
return f, rootCmd, nil
|
return f, rootCmd, nil
|
||||||
}
|
}
|
||||||
warnPolicyError(cfg.streams.ErrOut, err)
|
warnPolicyError(cfg.streams.ErrOut, policyErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The custom help command attaches AFTER policy evaluation on purpose:
|
||||||
|
// it is a framework meta command, and inside the evaluated tree an
|
||||||
|
// allow-list rule (Allow: ["im/**"]) would deny it as
|
||||||
|
// domain_not_allowed — cobra's stock help command is likewise attached
|
||||||
|
// only at Execute time and never evaluated.
|
||||||
|
installHelpCommand(rootCmd)
|
||||||
|
|
||||||
|
// Presentation passes: a capability an integrator plugin denied
|
||||||
|
// presents as absent — retired global flags (--profile), no skills
|
||||||
|
// footer, diagnostics hidden or retired. Enforcement stays with
|
||||||
|
// cmdpolicy.Apply above; these only shape help and fixed hints.
|
||||||
|
applyPluginPresentation(rootCmd, installResult, denied)
|
||||||
|
|
||||||
|
// Resolve the embedded skill tree BEFORE wiring hooks: an invalid
|
||||||
|
// SkillsOverlay must fail fast, before wireHooks emits Startup, so a Startup
|
||||||
|
// side effect is never stranded without its Shutdown. Both skill readers
|
||||||
|
// -- `skills list`/`read` and the --help guidance -- then read this one
|
||||||
|
// f.SkillContent. Fails closed: never silently ship defaults once a
|
||||||
|
// customization is declared.
|
||||||
|
resolvedSkills, skillErr := resolveSkillContent(cfg, pluginSkills)
|
||||||
|
if skillErr != nil {
|
||||||
|
installPluginSkillErrorGuard(rootCmd, skillErr)
|
||||||
|
return f, rootCmd, nil
|
||||||
|
}
|
||||||
|
f.SkillContent = resolvedSkills
|
||||||
|
|
||||||
if registry != nil {
|
if registry != nil {
|
||||||
if err := wireHooks(ctx, rootCmd, registry); err != nil {
|
if err := wireHooks(ctx, rootCmd, registry); err != nil {
|
||||||
installPluginLifecycleErrorGuard(rootCmd, err)
|
installPluginLifecycleErrorGuard(rootCmd, err)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/i18n"
|
"github.com/larksuite/cli/internal/i18n"
|
||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/policystate"
|
||||||
)
|
)
|
||||||
|
|
||||||
type noopConfigKeychain struct{}
|
type noopConfigKeychain struct{}
|
||||||
@@ -564,3 +565,54 @@ func TestPrintLangPreferenceConfirmation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The "no active profile" hint points at `lark-cli profile list`; that pointer
|
||||||
|
// must be gated on the profile domain still being present. When a plugin denies
|
||||||
|
// the profile domain, the hint would be a dead end, so it is omitted — the error
|
||||||
|
// itself is unchanged.
|
||||||
|
func TestConfigShowRun_ProfileHintGatedByPluginDenial(t *testing.T) {
|
||||||
|
multi := &core.MultiAppConfig{
|
||||||
|
CurrentApp: "missing",
|
||||||
|
Apps: []core.AppConfig{{
|
||||||
|
Name: "default",
|
||||||
|
AppId: "app-default",
|
||||||
|
AppSecret: core.PlainSecret("secret-default"),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
hintOf := func(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||||
|
var cfgErr *errs.ConfigError
|
||||||
|
if !errors.As(err, &cfgErr) {
|
||||||
|
t.Fatalf("expected *errs.ConfigError, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||||
|
t.Fatalf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||||
|
}
|
||||||
|
return cfgErr.Hint
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("profile domain present: hint points at profile list", func(t *testing.T) {
|
||||||
|
policystate.ResetForTesting()
|
||||||
|
t.Cleanup(policystate.ResetForTesting)
|
||||||
|
if h := hintOf(t); !strings.Contains(h, "lark-cli profile list") {
|
||||||
|
t.Errorf("hint = %q, want it to reference `lark-cli profile list`", h)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("profile domain plugin-denied: hint omitted", func(t *testing.T) {
|
||||||
|
policystate.ResetForTesting()
|
||||||
|
t.Cleanup(policystate.ResetForTesting)
|
||||||
|
policystate.SetPluginDeniedDomains(map[string]bool{"profile": true})
|
||||||
|
if h := hintOf(t); h != "" {
|
||||||
|
t.Errorf("hint = %q, want empty when the profile domain is plugin-denied", h)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
|
|||||||
if len(p.Rules) > 0 {
|
if len(p.Rules) > 0 {
|
||||||
entry["rules"] = p.Rules
|
entry["rules"] = p.Rules
|
||||||
}
|
}
|
||||||
|
if p.EmbeddedSkills != nil {
|
||||||
|
entry["embedded_skills"] = p.EmbeddedSkills
|
||||||
|
}
|
||||||
entry["hooks"] = map[string]any{
|
entry["hooks"] = map[string]any{
|
||||||
"observers": p.Observers,
|
"observers": p.Observers,
|
||||||
"wrappers": p.Wrappers,
|
"wrappers": p.Wrappers,
|
||||||
|
|||||||
93
cmd/config/plugins_test.go
Normal file
93
cmd/config/plugins_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// config plugins show must surface a plugin's EmbeddedSkills contribution in
|
||||||
|
// the rendered JSON, not only in the internal inventory struct: this command is
|
||||||
|
// the operator's window into what a fork trimmed, so the Allow/Remove/Overlay/
|
||||||
|
// Base summary has to reach stdout. Guards the render layer, which asserting the
|
||||||
|
// inventory struct alone does not exercise.
|
||||||
|
func TestConfigPluginsShow_RendersEmbeddedSkills(t *testing.T) {
|
||||||
|
internalplatform.SetActiveInventory(&internalplatform.Inventory{
|
||||||
|
Plugins: []internalplatform.PluginEntry{{
|
||||||
|
Name: "acme",
|
||||||
|
Version: "1.0",
|
||||||
|
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
|
||||||
|
EmbeddedSkills: &internalplatform.SkillsOverlayView{
|
||||||
|
Allow: []string{"lark-im"},
|
||||||
|
Remove: []string{"lark-a"},
|
||||||
|
Overlay: true,
|
||||||
|
Base: true,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
|
||||||
|
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
|
||||||
|
if err := runConfigPluginsShow(f); err != nil {
|
||||||
|
t.Fatalf("show: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Plugins []struct {
|
||||||
|
EmbeddedSkills *internalplatform.SkillsOverlayView `json:"embedded_skills"`
|
||||||
|
} `json:"plugins"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
if len(got.Plugins) != 1 {
|
||||||
|
t.Fatalf("want 1 plugin, got %d", len(got.Plugins))
|
||||||
|
}
|
||||||
|
es := got.Plugins[0].EmbeddedSkills
|
||||||
|
if es == nil {
|
||||||
|
t.Fatalf("embedded_skills missing from rendered output:\n%s", out.String())
|
||||||
|
}
|
||||||
|
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
|
||||||
|
len(es.Remove) != 1 || es.Remove[0] != "lark-a" ||
|
||||||
|
!es.Overlay || !es.Base {
|
||||||
|
t.Errorf("embedded_skills summary mismatch: %+v", es)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plugin that did not customize embedded skills must not emit an
|
||||||
|
// embedded_skills key, so the field's presence is a reliable signal that a fork
|
||||||
|
// trimmed the tree.
|
||||||
|
func TestConfigPluginsShow_OmitsEmbeddedSkillsWhenAbsent(t *testing.T) {
|
||||||
|
internalplatform.SetActiveInventory(&internalplatform.Inventory{
|
||||||
|
Plugins: []internalplatform.PluginEntry{{
|
||||||
|
Name: "acme",
|
||||||
|
Version: "1.0",
|
||||||
|
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
|
||||||
|
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
|
||||||
|
if err := runConfigPluginsShow(f); err != nil {
|
||||||
|
t.Fatalf("show: %v", err)
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
|
||||||
|
t.Fatalf("not json: %v", err)
|
||||||
|
}
|
||||||
|
plugins, ok := raw["plugins"].([]any)
|
||||||
|
if !ok || len(plugins) != 1 {
|
||||||
|
t.Fatalf("want 1 plugin in output, got: %s", out.String())
|
||||||
|
}
|
||||||
|
if _, ok := plugins[0].(map[string]any)["embedded_skills"]; ok {
|
||||||
|
t.Errorf("embedded_skills must be omitted when the plugin customized no skills; got:\n%s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ 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"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/policystate"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,7 +56,12 @@ func configShowRun(opts *ConfigShowOptions) error {
|
|||||||
}
|
}
|
||||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||||
if app == nil {
|
if app == nil {
|
||||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
e := errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile")
|
||||||
|
// No recovery hint when the profile domain is absent from this build.
|
||||||
|
if !policystate.DomainDeniedByPlugin("profile") {
|
||||||
|
e = e.WithHint("run: lark-cli profile list")
|
||||||
|
}
|
||||||
|
return e
|
||||||
}
|
}
|
||||||
users := "(no logged-in users)"
|
users := "(no logged-in users)"
|
||||||
if len(app.Users) > 0 {
|
if len(app.Users) > 0 {
|
||||||
|
|||||||
109
cmd/flag_gate.go
Normal file
109
cmd/flag_gate.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// globalFlagDomains maps each root persistent flag to the command domain
|
||||||
|
// it belongs to. A new domain-tied global flag must add a row.
|
||||||
|
var globalFlagDomains = map[string]string{
|
||||||
|
"profile": "profile",
|
||||||
|
}
|
||||||
|
|
||||||
|
// flagGateAnnotation distinguishes a policy-retired flag from one hidden
|
||||||
|
// cosmetically (single-app mode force-shows the latter in root help).
|
||||||
|
const flagGateAnnotation = "lark:policy_denied_flag"
|
||||||
|
|
||||||
|
// applyPluginFlagGate hides and rejects the global flags whose whole
|
||||||
|
// domain a plugin denied. yaml denials do not gate flags. Must run after
|
||||||
|
// RegisterGlobalFlags and cmdpolicy.Apply.
|
||||||
|
func applyPluginFlagGate(root *cobra.Command, denied map[string]cmdpolicy.Denial) {
|
||||||
|
gated := false
|
||||||
|
for flagName, domain := range globalFlagDomains {
|
||||||
|
d, ok := denied[domain]
|
||||||
|
if !ok || !cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fl := root.PersistentFlags().Lookup(flagName)
|
||||||
|
if fl == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fl.Hidden = true
|
||||||
|
if fl.Annotations == nil {
|
||||||
|
fl.Annotations = map[string][]string{}
|
||||||
|
}
|
||||||
|
fl.Annotations[flagGateAnnotation] = []string{"true"}
|
||||||
|
gated = true
|
||||||
|
}
|
||||||
|
if gated {
|
||||||
|
installFlagGateRejection(root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPolicyGatedFlag(fl *pflag.Flag) bool {
|
||||||
|
return fl != nil && fl.Annotations[flagGateAnnotation] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// installFlagGateRejection rejects gated flags right after parsing.
|
||||||
|
// pflag has no runtime unregister, so the flag still parses; and cobra
|
||||||
|
// resolves PersistentPreRunE "first non-nil wins" walking up from the
|
||||||
|
// leaf, so every command carrying its own (cmd/auth, cmd/config) must be
|
||||||
|
// wrapped too, not just the root.
|
||||||
|
func installFlagGateRejection(root *cobra.Command) {
|
||||||
|
var walk func(c *cobra.Command)
|
||||||
|
walk = func(c *cobra.Command) {
|
||||||
|
if prev := c.PersistentPreRunE; prev != nil {
|
||||||
|
c.PersistentPreRunE = func(cc *cobra.Command, args []string) error {
|
||||||
|
if err := rejectGatedFlags(cc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return prev(cc, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range c.Commands() {
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevRun := root.PersistentPreRun
|
||||||
|
root.PersistentPreRun = nil
|
||||||
|
root.PersistentPreRunE = func(c *cobra.Command, args []string) error {
|
||||||
|
if err := rejectGatedFlags(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if prevRun != nil {
|
||||||
|
prevRun(c, args)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, child := range root.Commands() {
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejectGatedFlags fails a set policy-retired flag with the same shape an
|
||||||
|
// unregistered flag produces (see flagDidYouMean).
|
||||||
|
//
|
||||||
|
// VisitAll + fl.Changed, not Visit: cobra parses a persistent flag on the leaf
|
||||||
|
// command's merged flagset, so root.PersistentFlags()'s "changed" table stays
|
||||||
|
// empty and Visit (which only walks that table) never fires on the dispatch
|
||||||
|
// path. The flag objects are shared by pointer across the merge, so fl.Changed
|
||||||
|
// reflects a real leaf-level parse.
|
||||||
|
func rejectGatedFlags(c *cobra.Command) error {
|
||||||
|
var rejected error
|
||||||
|
c.Root().PersistentFlags().VisitAll(func(fl *pflag.Flag) {
|
||||||
|
if rejected == nil && fl.Changed && isPolicyGatedFlag(fl) {
|
||||||
|
rejected = errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"unknown flag %q for %q", "--"+fl.Name, c.CommandPath()).
|
||||||
|
WithParams(errs.InvalidParam{Name: "--" + fl.Name, Reason: "unknown flag"}).
|
||||||
|
WithHint("run `%s --help` to see valid flags", c.CommandPath())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return rejected
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/hook"
|
"github.com/larksuite/cli/internal/hook"
|
||||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||||
|
"github.com/larksuite/cli/internal/policystate"
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,7 +36,15 @@ const userPolicyFileName = "policy.yml"
|
|||||||
//
|
//
|
||||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||||
// the InstallAll phase; nil/empty is fine.
|
// the InstallAll phase; nil/empty is fine.
|
||||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
//
|
||||||
|
// The returned denied map (nil when no rule denied anything) feeds the
|
||||||
|
// post-pruning presentation passes in build.go: the global-flag gate and
|
||||||
|
// the fixed-hint filter both key off which domains a plugin denied.
|
||||||
|
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) (map[string]cmdpolicy.Denial, error) {
|
||||||
|
// Reset up front so every early return leaves the process-global
|
||||||
|
// snapshot clean; the success path re-populates it.
|
||||||
|
policystate.SetPluginDeniedDomains(nil)
|
||||||
|
|
||||||
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
|
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
|
||||||
// yaml). When a plugin contributed rules we therefore do NOT even
|
// yaml). When a plugin contributed rules we therefore do NOT even
|
||||||
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
|
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
|
||||||
@@ -65,7 +74,7 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
|||||||
// show` reports "no policy" instead of a stale rule that
|
// show` reports "no policy" instead of a stale rule that
|
||||||
// doesn't reflect the current command tree.
|
// doesn't reflect the current command tree.
|
||||||
cmdpolicy.SetActive(nil)
|
cmdpolicy.SetActive(nil)
|
||||||
return lerr
|
return nil, lerr
|
||||||
}
|
}
|
||||||
yamlRules = loaded
|
yamlRules = loaded
|
||||||
}
|
}
|
||||||
@@ -77,11 +86,11 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cmdpolicy.SetActive(nil)
|
cmdpolicy.SetActive(nil)
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(rules) == 0 {
|
if len(rules) == 0 {
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuleName attributes a denial to a specific rule in the envelope.
|
// RuleName attributes a denial to a specific rule in the envelope.
|
||||||
@@ -94,17 +103,39 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
|||||||
ruleName = rules[0].Name
|
ruleName = rules[0].Name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeniedMessage is build-level: the first non-empty message across
|
||||||
|
// the single owner's rules speaks for all of them.
|
||||||
|
deniedMessage := ""
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.DeniedMessage != "" {
|
||||||
|
deniedMessage = r.DeniedMessage
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
engine := cmdpolicy.NewSet(rules)
|
engine := cmdpolicy.NewSet(rules)
|
||||||
decisions := engine.EvaluateAll(rootCmd)
|
decisions := engine.EvaluateAll(rootCmd)
|
||||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName)
|
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName, deniedMessage)
|
||||||
cmdpolicy.Apply(rootCmd, denied)
|
cmdpolicy.Apply(rootCmd, denied)
|
||||||
|
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||||
Rules: rules,
|
Rules: rules,
|
||||||
Source: source,
|
Source: source,
|
||||||
DeniedPaths: len(denied),
|
DeniedPaths: len(denied),
|
||||||
|
DeniedByPath: denied,
|
||||||
})
|
})
|
||||||
return nil
|
|
||||||
|
// Whole-domain denials surface as aggregate entries keyed by the
|
||||||
|
// bare domain name (no slash); record them for render-time hint
|
||||||
|
// emitters (internal/auth, internal/client, the notice provider).
|
||||||
|
pluginDomains := map[string]bool{}
|
||||||
|
for path, d := range denied {
|
||||||
|
if !strings.Contains(path, "/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||||
|
pluginDomains[path] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
policystate.SetPluginDeniedDomains(pluginDomains)
|
||||||
|
return denied, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// installPluginsAndHooks runs the InstallAll phase on the globally-
|
// installPluginsAndHooks runs the InstallAll phase on the globally-
|
||||||
@@ -156,7 +187,22 @@ func recordInventory(installResult *internalplatform.InstallResult) {
|
|||||||
AllowUnannotated: r.Rule.AllowUnannotated,
|
AllowUnannotated: r.Rule.AllowUnannotated,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
|
skillSrcs := make([]internalplatform.SkillsInventorySource, 0, len(installResult.PluginSkills))
|
||||||
|
for _, ps := range installResult.PluginSkills {
|
||||||
|
if ps.SkillsOverlay == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skillSrcs = append(skillSrcs, internalplatform.SkillsInventorySource{
|
||||||
|
PluginName: ps.PluginName,
|
||||||
|
View: internalplatform.SkillsOverlayView{
|
||||||
|
Allow: ps.SkillsOverlay.Allow,
|
||||||
|
Remove: ps.SkillsOverlay.Remove,
|
||||||
|
Overlay: ps.SkillsOverlay.Overlay != nil,
|
||||||
|
Base: ps.SkillsOverlay.Base != nil,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs, skillSrcs))
|
||||||
}
|
}
|
||||||
|
|
||||||
// wireHooks installs Observer/Wrapper hooks onto every runnable command
|
// wireHooks installs Observer/Wrapper hooks onto every runnable command
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ max_risk: write
|
|||||||
`)
|
`)
|
||||||
|
|
||||||
root := fakeTree(t)
|
root := fakeTree(t)
|
||||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
if _, err := applyUserPolicyPruning(root, nil); err != nil {
|
||||||
t.Fatalf("apply policy: %v", err)
|
t.Fatalf("apply policy: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
|
|||||||
tmpHome(t) // home set but no policy.yml written
|
tmpHome(t) // home set but no policy.yml written
|
||||||
|
|
||||||
root := fakeTree(t)
|
root := fakeTree(t)
|
||||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
if _, err := applyUserPolicyPruning(root, nil); err != nil {
|
||||||
t.Fatalf("missing policy should not error, got %v", err)
|
t.Fatalf("missing policy should not error, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
|
|||||||
writePolicy(t, cfgDir, "::: not yaml :::")
|
writePolicy(t, cfgDir, "::: not yaml :::")
|
||||||
|
|
||||||
root := fakeTree(t)
|
root := fakeTree(t)
|
||||||
err := applyUserPolicyPruning(root, nil)
|
_, err := applyUserPolicyPruning(root, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("malformed yaml should produce an error")
|
t.Fatalf("malformed yaml should produce an error")
|
||||||
}
|
}
|
||||||
@@ -221,7 +221,7 @@ func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
root := fakeTree(t)
|
root := fakeTree(t)
|
||||||
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
if _, err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
||||||
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
|
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
|
|||||||
writePolicy(t, cfgDir, "max_risk: nukem\n")
|
writePolicy(t, cfgDir, "max_risk: nukem\n")
|
||||||
|
|
||||||
root := fakeTree(t)
|
root := fakeTree(t)
|
||||||
err := applyUserPolicyPruning(root, nil)
|
_, err := applyUserPolicyPruning(root, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("invalid MaxRisk should produce an error")
|
t.Fatalf("invalid MaxRisk should produce an error")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
"github.com/larksuite/cli/internal/hook"
|
"github.com/larksuite/cli/internal/hook"
|
||||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||||
|
"github.com/larksuite/cli/internal/skillpolicy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// installFatalGuard wires a fail-closed guard at every cobra dispatch
|
// installFatalGuard wires a fail-closed guard at every cobra dispatch
|
||||||
@@ -110,6 +111,27 @@ func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
|||||||
installFatalGuard(rootCmd, makeErr)
|
installFatalGuard(rootCmd, makeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// installPluginSkillErrorGuard surfaces a plugin SkillsOverlay configuration
|
||||||
|
// error before any command runs. Two failure modes, split by reason code:
|
||||||
|
//
|
||||||
|
// - "invalid_skills_overlay" - a Remove/Overlay that cannot compose
|
||||||
|
// - "multiple_skills_overlay_plugins" - two plugins each customizing skills
|
||||||
|
//
|
||||||
|
// The CLI must NOT silently fall back to default skills once an
|
||||||
|
// integrator has declared a customization.
|
||||||
|
func installPluginSkillErrorGuard(rootCmd *cobra.Command, err error) {
|
||||||
|
makeErr := func() error {
|
||||||
|
reasonCode := internalplatform.ReasonInvalidSkillsOverlay
|
||||||
|
if errors.Is(err, skillpolicy.ErrMultipleSkillsOverlays) {
|
||||||
|
reasonCode = internalplatform.ReasonMultipleSkillsOverlays
|
||||||
|
}
|
||||||
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
|
||||||
|
WithHint("plugin skill customization is broken (reason_code %s); fix the plugin's SkillsOverlay or remove the conflicting plugin", reasonCode).
|
||||||
|
WithCause(err)
|
||||||
|
}
|
||||||
|
installFatalGuard(rootCmd, makeErr)
|
||||||
|
}
|
||||||
|
|
||||||
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
||||||
// failure as a typed validation error (failed_precondition). The hint's
|
// failure as a typed validation error (failed_precondition). The hint's
|
||||||
// reason code splits returned-error vs panic so consumers (audit /
|
// reason code splits returned-error vs panic so consumers (audit /
|
||||||
|
|||||||
169
cmd/presentation.go
Normal file
169
cmd/presentation.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
|
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// applyPluginPresentation makes plugin-denied capabilities present as
|
||||||
|
// absent: retired flags, no skills footer, diagnostics hidden or retired.
|
||||||
|
// Presentation only — enforcement happened in cmdpolicy.Apply.
|
||||||
|
func applyPluginPresentation(rootCmd *cobra.Command, installResult *internalplatform.InstallResult, denied map[string]cmdpolicy.Denial) {
|
||||||
|
applyPluginFlagGate(rootCmd, denied)
|
||||||
|
|
||||||
|
if domainDeniedByPlugin(denied, "skills") {
|
||||||
|
rootCmd.SetUsageTemplate(strings.Replace(rootUsageTemplate, skillsSetupFooter, "", 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
if installResult != nil && hideDiagnosticsOwner(installResult.Plugins) != "" {
|
||||||
|
retireDiagnostics(rootCmd, installResult, denied)
|
||||||
|
} else {
|
||||||
|
concealDiagnostics(rootCmd, denied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainDeniedByPlugin reads the freshly-built denied map, unlike
|
||||||
|
// policystate.DomainDeniedByPlugin which serves render-time consumers.
|
||||||
|
func domainDeniedByPlugin(denied map[string]cmdpolicy.Denial, domain string) bool {
|
||||||
|
d, ok := denied[domain]
|
||||||
|
return ok && cmdpolicy.IsPluginPolicySource(d.PolicySource)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hideDiagnosticsOwner returns the plugin that declared HideDiagnostics,
|
||||||
|
// or "". The host already enforced that it also Restricts.
|
||||||
|
func hideDiagnosticsOwner(plugins []internalplatform.PluginInfo) string {
|
||||||
|
for _, p := range plugins {
|
||||||
|
if p.Capabilities.HideDiagnostics {
|
||||||
|
return p.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// retireDiagnostics installs the unavailable presentation on the
|
||||||
|
// diagnostic exemptions, same as any other plugin-denied command.
|
||||||
|
func retireDiagnostics(rootCmd *cobra.Command, installResult *internalplatform.InstallResult, denied map[string]cmdpolicy.Denial) {
|
||||||
|
source := "plugin:" + hideDiagnosticsOwner(installResult.Plugins)
|
||||||
|
message := ""
|
||||||
|
for _, d := range denied {
|
||||||
|
if cmdpolicy.IsPluginPolicySource(d.PolicySource) && d.DeniedMessage != "" {
|
||||||
|
message = d.DeniedMessage
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diag := map[string]cmdpolicy.Denial{}
|
||||||
|
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||||
|
diag[path] = cmdpolicy.Denial{
|
||||||
|
Layer: cmdpolicy.LayerPolicy,
|
||||||
|
PolicySource: source,
|
||||||
|
ReasonCode: "diagnostics_hidden",
|
||||||
|
Reason: "policy self-inspection hidden by the integrator",
|
||||||
|
DeniedMessage: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmdpolicy.Apply(rootCmd, diag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// concealDiagnostics hides the diagnostic exemptions from help — without
|
||||||
|
// touching their RunE, so they stay dispatchable — once every non-exempt
|
||||||
|
// sibling in their domain is already hidden. The group itself then gets
|
||||||
|
// the unavailable stub: it escaped denial aggregation only because the
|
||||||
|
// exemptions kept a runnable descendant alive, and its unknown-subcommand
|
||||||
|
// guard RunE would otherwise keep it listed in help.
|
||||||
|
func concealDiagnostics(rootCmd *cobra.Command, denied map[string]cmdpolicy.Denial) {
|
||||||
|
for _, group := range diagnosticDomainGroups(rootCmd) {
|
||||||
|
if !pluginDeniedUnder(denied, cmdpolicy.CanonicalPath(group)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exemptAncestors := map[*cobra.Command]bool{}
|
||||||
|
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||||
|
for c := findByPath(rootCmd, path); c != nil && c != group; c = c.Parent() {
|
||||||
|
exemptAncestors[c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allOthersHidden := true
|
||||||
|
for _, child := range group.Commands() {
|
||||||
|
if child.Name() == "help" || exemptAncestors[child] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !child.Hidden {
|
||||||
|
allOthersHidden = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allOthersHidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for c := range exemptAncestors {
|
||||||
|
c.Hidden = true
|
||||||
|
}
|
||||||
|
var sample cmdpolicy.Denial
|
||||||
|
for path, d := range denied {
|
||||||
|
if strings.HasPrefix(path, cmdpolicy.CanonicalPath(group)+"/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||||
|
sample = d
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmdpolicy.Apply(rootCmd, map[string]cmdpolicy.Denial{
|
||||||
|
cmdpolicy.CanonicalPath(group): {
|
||||||
|
Layer: cmdpolicy.LayerPolicy,
|
||||||
|
PolicySource: sample.PolicySource,
|
||||||
|
RuleName: sample.RuleName,
|
||||||
|
ReasonCode: "all_children_denied",
|
||||||
|
Reason: "all child commands are denied",
|
||||||
|
DeniedMessage: sample.DeniedMessage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// diagnosticDomainGroups returns the top-level groups containing a
|
||||||
|
// diagnostic exemption (today just `config`).
|
||||||
|
func diagnosticDomainGroups(rootCmd *cobra.Command) []*cobra.Command {
|
||||||
|
seen := map[*cobra.Command]bool{}
|
||||||
|
var out []*cobra.Command
|
||||||
|
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||||
|
top, _, _ := strings.Cut(path, "/")
|
||||||
|
if c := findByPath(rootCmd, top); c != nil && !seen[c] {
|
||||||
|
seen[c] = true
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginDeniedUnder(denied map[string]cmdpolicy.Denial, prefix string) bool {
|
||||||
|
for path, d := range denied {
|
||||||
|
if strings.HasPrefix(path, prefix+"/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// findByPath resolves a canonical slash path (e.g. "config/policy/show")
|
||||||
|
// to the command node, or nil.
|
||||||
|
func findByPath(rootCmd *cobra.Command, path string) *cobra.Command {
|
||||||
|
cur := rootCmd
|
||||||
|
for _, seg := range strings.Split(path, "/") {
|
||||||
|
var next *cobra.Command
|
||||||
|
for _, child := range cur.Commands() {
|
||||||
|
if child.Name() == seg {
|
||||||
|
next = child
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cur = next
|
||||||
|
}
|
||||||
|
return cur
|
||||||
|
}
|
||||||
397
cmd/presentation_test.go
Normal file
397
cmd/presentation_test.go
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/extension/platform"
|
||||||
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
|
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||||
|
"github.com/larksuite/cli/internal/policystate"
|
||||||
|
"github.com/larksuite/cli/internal/update"
|
||||||
|
)
|
||||||
|
|
||||||
|
// restrictingPlugin registers a plugin that denies the given globs; extra
|
||||||
|
// customizes the builder further (nil for none).
|
||||||
|
func restrictingPlugin(t *testing.T, deny []string, extra func(*platform.Builder) *platform.Builder) {
|
||||||
|
t.Helper()
|
||||||
|
platform.ResetForTesting()
|
||||||
|
t.Cleanup(platform.ResetForTesting)
|
||||||
|
t.Cleanup(func() { policystate.ResetForTesting() })
|
||||||
|
b := platform.NewPlugin("acme", "1.0").Restrict(&platform.Rule{Deny: deny})
|
||||||
|
if extra != nil {
|
||||||
|
b = extra(b)
|
||||||
|
}
|
||||||
|
platform.Register(b.MustBuild())
|
||||||
|
}
|
||||||
|
|
||||||
|
// runnableUnder returns the first runnable non-exempt descendant under
|
||||||
|
// the named top-level group of the real command tree (the diagnostic
|
||||||
|
// exemptions keep their original RunE and would not exercise the stub).
|
||||||
|
func runnableUnder(t *testing.T, root *cobra.Command, group string) *cobra.Command {
|
||||||
|
t.Helper()
|
||||||
|
g := findByPath(root, group)
|
||||||
|
if g == nil {
|
||||||
|
t.Fatalf("group %q not found in command tree", group)
|
||||||
|
}
|
||||||
|
var find func(c *cobra.Command) *cobra.Command
|
||||||
|
find = func(c *cobra.Command) *cobra.Command {
|
||||||
|
if c.RunE != nil && len(c.Commands()) == 0 && !cmdpolicy.IsDiagnosticPath(cmdpolicy.CanonicalPath(c)) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
for _, child := range c.Commands() {
|
||||||
|
if leaf := find(child); leaf != nil {
|
||||||
|
return leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
leaf := find(g)
|
||||||
|
if leaf == nil {
|
||||||
|
t.Fatalf("no runnable non-exempt leaf under %q", group)
|
||||||
|
}
|
||||||
|
return leaf
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plugin-denied command answers with command_unavailable: default
|
||||||
|
// message, no hint, no policy vocabulary.
|
||||||
|
func TestBuildInternal_pluginDenyPresentsUnavailable(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
leaf := runnableUnder(t, root, "config")
|
||||||
|
|
||||||
|
err := leaf.RunE(leaf, nil)
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||||
|
t.Errorf("subtype = %q, want command_unavailable", ve.Subtype)
|
||||||
|
}
|
||||||
|
if ve.Message != cmdpolicy.DefaultUnavailableMessage || ve.Hint != "" {
|
||||||
|
t.Errorf("message=%q hint=%q, want default message and empty hint", ve.Message, ve.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule.DeniedMessage speaks in the integrator's product voice.
|
||||||
|
func TestBuildInternal_deniedMessageOverride(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
platform.ResetForTesting()
|
||||||
|
t.Cleanup(platform.ResetForTesting)
|
||||||
|
t.Cleanup(func() { policystate.ResetForTesting() })
|
||||||
|
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||||
|
Restrict(&platform.Rule{Deny: []string{"config/**"}, DeniedMessage: "not part of acme cli"}).
|
||||||
|
MustBuild())
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
leaf := runnableUnder(t, root, "config")
|
||||||
|
|
||||||
|
err := leaf.RunE(leaf, nil)
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Message != "not part of acme cli" {
|
||||||
|
t.Errorf("message = %q, want the integrator's DeniedMessage", ve.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit help on a plugin-denied command must not render the original
|
||||||
|
// usage; it answers with the same unavailable envelope.
|
||||||
|
func TestBuildInternal_pluginDenyHelpIntercepted(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
leaf := runnableUnder(t, root, "config")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
leaf.SetOut(&buf)
|
||||||
|
leaf.SetErr(&buf)
|
||||||
|
root.HelpFunc()(leaf, nil)
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "command_unavailable") {
|
||||||
|
t.Errorf("explicit help must answer command_unavailable, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "Usage:") {
|
||||||
|
t.Errorf("explicit help must not render the original usage, got:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// yaml-source presentation is untouched: a command outside the denied
|
||||||
|
// domain still renders normal help.
|
||||||
|
var normal bytes.Buffer
|
||||||
|
alive := findByPath(root, "skills")
|
||||||
|
alive.SetOut(&normal)
|
||||||
|
alive.SetErr(&normal)
|
||||||
|
root.HelpFunc()(alive, nil)
|
||||||
|
if strings.Contains(normal.String(), "command_unavailable") {
|
||||||
|
t.Errorf("non-denied command help must render normally, got:\n%s", normal.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `lark-cli help <plugin-restricted-cmd>` must fail with the typed
|
||||||
|
// unavailable error (exit non-zero), not print an envelope and exit 0.
|
||||||
|
func TestBuildInternal_helpCommandReturnsUnavailable(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
helpCmd := findByPath(root, "help")
|
||||||
|
if helpCmd == nil || helpCmd.RunE == nil {
|
||||||
|
t.Fatal("custom help command not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := helpCmd.RunE(helpCmd, []string{"config"})
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||||
|
t.Errorf("help on a restricted command must return command_unavailable, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A live target renders normally and returns nil.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
root.SetOut(&buf)
|
||||||
|
root.SetErr(&buf)
|
||||||
|
if err := helpCmd.RunE(helpCmd, []string{"skills"}); err != nil {
|
||||||
|
t.Errorf("help on a live command must succeed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// help is a framework meta command: an allow-list rule must not deny it.
|
||||||
|
// `help <live-cmd>` renders; `help <denied-cmd>` returns unavailable.
|
||||||
|
func TestBuildInternal_helpSurvivesAllowList(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
platform.ResetForTesting()
|
||||||
|
t.Cleanup(platform.ResetForTesting)
|
||||||
|
t.Cleanup(func() { policystate.ResetForTesting() })
|
||||||
|
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||||
|
Restrict(&platform.Rule{Allow: []string{"im/**"}, AllowUnannotated: true}).
|
||||||
|
MustBuild())
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
helpCmd := findByPath(root, "help")
|
||||||
|
if helpCmd == nil || helpCmd.Annotations[cmdpolicy.AnnotationDenialLayer] != "" {
|
||||||
|
t.Fatalf("help must not be policy-denied under an allow-list; annotations=%v", helpCmd.Annotations)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
root.SetOut(&buf)
|
||||||
|
root.SetErr(&buf)
|
||||||
|
if err := helpCmd.RunE(helpCmd, []string{"im"}); err != nil {
|
||||||
|
t.Errorf("help on an allowed domain must render, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := helpCmd.RunE(helpCmd, []string{"config"})
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||||
|
t.Errorf("help on a denied domain must return command_unavailable, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plugin inventory surfaces the new contributions so `config plugins
|
||||||
|
// show` can answer "what did this build customize".
|
||||||
|
func TestBuildInternal_inventoryCoversNewCapabilities(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||||
|
restrictingPlugin(t, []string{"profile/**"}, func(b *platform.Builder) *platform.Builder {
|
||||||
|
return b.HideDiagnostics().
|
||||||
|
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
|
||||||
|
})
|
||||||
|
|
||||||
|
buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
inv := internalplatform.GetActiveInventory()
|
||||||
|
if inv == nil || len(inv.Plugins) != 1 {
|
||||||
|
t.Fatalf("inventory = %+v, want 1 plugin", inv)
|
||||||
|
}
|
||||||
|
p := inv.Plugins[0]
|
||||||
|
if !p.Capabilities.HideDiagnostics {
|
||||||
|
t.Error("inventory must surface HideDiagnostics")
|
||||||
|
}
|
||||||
|
if p.EmbeddedSkills == nil || len(p.EmbeddedSkills.Remove) != 1 || p.EmbeddedSkills.Remove[0] != "lark-a" {
|
||||||
|
t.Errorf("inventory must summarise EmbeddedSkills, got %+v", p.EmbeddedSkills)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Denying the whole profile domain retires --profile: hidden from help,
|
||||||
|
// and setting it fails like an unknown flag.
|
||||||
|
func TestBuildInternal_profileFlagGate(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
|
||||||
|
fl := root.PersistentFlags().Lookup("profile")
|
||||||
|
if fl == nil {
|
||||||
|
t.Fatal("--profile not registered")
|
||||||
|
}
|
||||||
|
if !fl.Hidden || !isPolicyGatedFlag(fl) {
|
||||||
|
t.Errorf("flag should be hidden and policy-gated; hidden=%v gated=%v", fl.Hidden, isPolicyGatedFlag(fl))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setting the flag (as cobra's parse would) must be rejected as unknown.
|
||||||
|
if err := root.PersistentFlags().Set("profile", "prod"); err != nil {
|
||||||
|
t.Fatalf("Set: %v", err)
|
||||||
|
}
|
||||||
|
err := rejectGatedFlags(root)
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) || !strings.Contains(ve.Message, "unknown flag") {
|
||||||
|
t.Errorf("setting a gated flag must fail as unknown flag, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gate must also fire on the real dispatch path. cobra parses a persistent
|
||||||
|
// flag on the leaf command's merged flagset, so a gate that walks
|
||||||
|
// root.PersistentFlags()'s changed table is a no-op once the flag is passed to
|
||||||
|
// a subcommand. Drive root.Execute end to end and confirm the gated --profile
|
||||||
|
// is rejected before the command body runs.
|
||||||
|
func TestBuildInternal_profileFlagGate_RejectsOnDispatch(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
|
||||||
|
ranBody := false
|
||||||
|
root.AddCommand(&cobra.Command{
|
||||||
|
Use: "gateprobe",
|
||||||
|
RunE: func(*cobra.Command, []string) error { ranBody = true; return nil },
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
root.SetOut(&buf)
|
||||||
|
root.SetErr(&buf)
|
||||||
|
root.SetArgs([]string{"--profile", "prod", "gateprobe"})
|
||||||
|
|
||||||
|
err := root.Execute()
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) || !strings.Contains(ve.Message, "unknown flag") {
|
||||||
|
t.Errorf("a gated --profile passed on the dispatch path must fail as unknown flag, got %v", err)
|
||||||
|
}
|
||||||
|
if ranBody {
|
||||||
|
t.Error("gated --profile must be rejected before the command body runs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the profile domain denied, the gate stays inert.
|
||||||
|
func TestBuildInternal_profileFlagUntouchedWithoutDenial(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
if fl := root.PersistentFlags().Lookup("profile"); isPolicyGatedFlag(fl) {
|
||||||
|
t.Error("--profile must not be gated when its domain is not denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Denying the skills domain drops the root-help skills-setup footer.
|
||||||
|
func TestBuildInternal_skillsFooterSuppressed(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
restrictingPlugin(t, []string{"skills", "skills/**"}, nil)
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
if strings.Contains(root.UsageTemplate(), "Skills setup") {
|
||||||
|
t.Error("skills-setup footer must be dropped when the skills domain is denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control: without the denial the footer stays.
|
||||||
|
platform.ResetForTesting()
|
||||||
|
policystate.ResetForTesting()
|
||||||
|
_, root2, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
if !strings.Contains(root2.UsageTemplate(), "Skills setup") {
|
||||||
|
t.Error("skills-setup footer must stay in the default build")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Denying the skills command domain kills every `skills read` pointer in
|
||||||
|
// domain help, even when the skill content itself is still embedded — the
|
||||||
|
// command the pointers name is absent, so they would all be dead ends.
|
||||||
|
func TestBuildInternal_skillsDomainDenyKillsHelpPointers(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
withBaseSkills(t, map[string]string{"lark-im/SKILL.md": "---\ndescription: im\n---\n"})
|
||||||
|
restrictingPlugin(t, []string{"skills", "skills/**"}, nil)
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
|
||||||
|
im := findByPath(root, "im")
|
||||||
|
if im == nil {
|
||||||
|
t.Fatal("im domain not in tree")
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
im.SetOut(&buf)
|
||||||
|
im.SetErr(&buf)
|
||||||
|
root.HelpFunc()(im, nil)
|
||||||
|
if strings.Contains(buf.String(), "skills read") {
|
||||||
|
t.Errorf("domain help must not point at the denied skills command, got:\n%s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Denying the update domain silences the _notice providers that would
|
||||||
|
// steer the caller to `lark-cli update`.
|
||||||
|
func TestComposePendingNotice_updateDomainDenied(t *testing.T) {
|
||||||
|
update.SetPending(&update.UpdateInfo{Current: "1.0.0", Latest: "1.0.1"})
|
||||||
|
t.Cleanup(func() { update.SetPending(nil) })
|
||||||
|
t.Cleanup(policystate.ResetForTesting)
|
||||||
|
|
||||||
|
policystate.SetPluginDeniedDomains(map[string]bool{"update": true})
|
||||||
|
if got := composePendingNotice(); got != nil {
|
||||||
|
t.Errorf("notices must be silenced with the update domain denied, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
policystate.SetPluginDeniedDomains(nil)
|
||||||
|
if got := composePendingNotice(); got == nil {
|
||||||
|
t.Error("notices must render without the denial")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: diagnostics stay executable but leave help when their whole
|
||||||
|
// domain is denied — cobra then drops the empty config group entirely.
|
||||||
|
func TestBuildInternal_concealDiagnostics(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
|
||||||
|
show := findByPath(root, "config/policy/show")
|
||||||
|
if show == nil {
|
||||||
|
t.Fatal("config policy show not in tree")
|
||||||
|
}
|
||||||
|
if !show.Hidden {
|
||||||
|
t.Error("exempt diagnostic should be hidden from help when its domain is denied")
|
||||||
|
}
|
||||||
|
// Still dispatchable: its RunE is the original, not an unavailable stub.
|
||||||
|
if show.Annotations[cmdpolicy.AnnotationDenialLayer] != "" {
|
||||||
|
t.Error("exempt diagnostic must not carry a denial stub by default")
|
||||||
|
}
|
||||||
|
if cfg := findByPath(root, "config"); cfg.IsAvailableCommand() {
|
||||||
|
t.Error("config group with no visible children must drop from help")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HideDiagnostics retires the exemptions like any other denied command.
|
||||||
|
func TestBuildInternal_hideDiagnostics(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
|
||||||
|
return b.HideDiagnostics()
|
||||||
|
})
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
|
||||||
|
show := findByPath(root, "config/policy/show")
|
||||||
|
if show == nil {
|
||||||
|
t.Fatal("config policy show not in tree")
|
||||||
|
}
|
||||||
|
err := show.RunE(show, nil)
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||||
|
t.Errorf("hidden diagnostic must answer command_unavailable, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/policystate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||||
@@ -105,8 +106,14 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
|||||||
},
|
},
|
||||||
RunE: func(c *cobra.Command, _ []string) error {
|
RunE: func(c *cobra.Command, _ []string) error {
|
||||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||||
|
hint := fmt.Sprintf("denied by %s policy (reason_code %s)", cd.Layer, cd.ReasonCode)
|
||||||
|
// The switch-policy pointer names `config strict-mode`; with
|
||||||
|
// the config domain plugin-denied it would be a dead end.
|
||||||
|
if !policystate.DomainDeniedByPlugin("config") {
|
||||||
|
hint += "; " + stubHint
|
||||||
|
}
|
||||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
|
||||||
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
|
WithHint("%s", hint).
|
||||||
WithCause(cd)
|
WithCause(cd)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/policystate"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -379,3 +380,41 @@ func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
|
|||||||
t.Errorf("denial annotation overwritten or missing")
|
t.Errorf("denial annotation overwritten or missing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The strict-mode stub's RunE appends a `config strict-mode` switch-policy
|
||||||
|
// pointer to its hint — but only when the config domain is still present. With
|
||||||
|
// the config domain plugin-denied that pointer is a dead end, so it is omitted;
|
||||||
|
// the denial itself (message, subtype) is unchanged.
|
||||||
|
func TestStrictModeStub_ConfigHintGatedByPluginDenial(t *testing.T) {
|
||||||
|
hintOf := func(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
child := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||||
|
stub := strictModeStubFrom(child, core.StrictModeBot)
|
||||||
|
err := stub.RunE(stub, nil)
|
||||||
|
var verr *errs.ValidationError
|
||||||
|
if !errors.As(err, &verr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
|
t.Fatalf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||||
|
}
|
||||||
|
return verr.Hint
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("config domain present: switch-policy pointer included", func(t *testing.T) {
|
||||||
|
policystate.ResetForTesting()
|
||||||
|
t.Cleanup(policystate.ResetForTesting)
|
||||||
|
if h := hintOf(t); !strings.Contains(h, "config strict-mode") {
|
||||||
|
t.Errorf("hint = %q, want it to reference `config strict-mode`", h)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("config domain plugin-denied: switch-policy pointer omitted", func(t *testing.T) {
|
||||||
|
policystate.ResetForTesting()
|
||||||
|
t.Cleanup(policystate.ResetForTesting)
|
||||||
|
policystate.SetPluginDeniedDomains(map[string]bool{"config": true})
|
||||||
|
if h := hintOf(t); strings.Contains(h, "config strict-mode") {
|
||||||
|
t.Errorf("hint = %q, want no `config strict-mode` pointer when config is plugin-denied", h)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
93
cmd/root.go
93
cmd/root.go
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -21,6 +22,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/deprecation"
|
"github.com/larksuite/cli/internal/deprecation"
|
||||||
"github.com/larksuite/cli/internal/hook"
|
"github.com/larksuite/cli/internal/hook"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/policystate"
|
||||||
"github.com/larksuite/cli/internal/skillscheck"
|
"github.com/larksuite/cli/internal/skillscheck"
|
||||||
"github.com/larksuite/cli/internal/suggest"
|
"github.com/larksuite/cli/internal/suggest"
|
||||||
"github.com/larksuite/cli/internal/update"
|
"github.com/larksuite/cli/internal/update"
|
||||||
@@ -80,11 +82,16 @@ Global Flags:
|
|||||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||||
|
|
||||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
|
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}` + skillsSetupFooter + `
|
||||||
|
|
||||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
|
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// skillsSetupFooter is the root-help pointer at the human one-time skills
|
||||||
|
// setup. Split out so the presentation pass can drop it from the template
|
||||||
|
// when an integrator plugin denies the skills domain.
|
||||||
|
const skillsSetupFooter = `{{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
|
||||||
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
||||||
@@ -167,6 +174,10 @@ func setupNotices() {
|
|||||||
// Extracted from Execute so the composition is unit-testable.
|
// Extracted from Execute so the composition is unit-testable.
|
||||||
func composePendingNotice() map[string]interface{} {
|
func composePendingNotice() map[string]interface{} {
|
||||||
notice := map[string]interface{}{}
|
notice := map[string]interface{}{}
|
||||||
|
// All three notices steer the caller to `lark-cli update`.
|
||||||
|
if policystate.DomainDeniedByPlugin("update") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if info := update.GetPending(); info != nil {
|
if info := update.GetPending(); info != nil {
|
||||||
notice["update"] = map[string]interface{}{
|
notice["update"] = map[string]interface{}{
|
||||||
"current": info.Current,
|
"current": info.Current,
|
||||||
@@ -658,16 +669,80 @@ func visibleFlagNames(c *cobra.Command) []string {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// installHelpCommand replaces cobra's default help command so that
|
||||||
|
// `lark-cli help <plugin-restricted-cmd>` returns a typed error (exit 2)
|
||||||
|
// instead of printing an envelope and exiting 0 — cobra's stock help
|
||||||
|
// command has no error channel.
|
||||||
|
func installHelpCommand(root *cobra.Command) {
|
||||||
|
helpCmd := &cobra.Command{
|
||||||
|
Use: "help [command]",
|
||||||
|
Short: "Help about any command",
|
||||||
|
Long: "Help provides help for any command in the application.",
|
||||||
|
RunE: func(c *cobra.Command, args []string) error {
|
||||||
|
target, _, err := root.Find(args)
|
||||||
|
if err != nil || target == nil {
|
||||||
|
c.Printf("Unknown help topic %#q\n", args)
|
||||||
|
return root.Usage()
|
||||||
|
}
|
||||||
|
if msg, ok := unavailableHelpMessage(target); ok {
|
||||||
|
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg)
|
||||||
|
}
|
||||||
|
target.InitDefaultHelpFlag()
|
||||||
|
return target.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// help attaches after policy evaluation (framework meta command, never
|
||||||
|
// policy-evaluated); the read annotation is defensive in case a future
|
||||||
|
// pass re-evaluates the finished tree.
|
||||||
|
cmdutil.SetRisk(helpCmd, "read")
|
||||||
|
cmdutil.DisableAuthCheck(helpCmd)
|
||||||
|
root.SetHelpCommand(helpCmd)
|
||||||
|
// SetHelpCommand alone defers attachment to Execute's
|
||||||
|
// InitDefaultHelpCmd; add it now so the built tree is complete
|
||||||
|
// (InitDefaultHelpCmd re-adds idempotently).
|
||||||
|
root.AddCommand(helpCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unavailableHelpMessage returns the message to render in place of help
|
||||||
|
// for a plugin-restricted command. yaml-source denials keep original help.
|
||||||
|
func unavailableHelpMessage(cmd *cobra.Command) (string, bool) {
|
||||||
|
if cmd == nil || cmd.Annotations == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if cmd.Annotations[cmdpolicy.AnnotationDenialLayer] != cmdpolicy.LayerPolicy ||
|
||||||
|
!cmdpolicy.IsPluginPolicySource(cmd.Annotations[cmdpolicy.AnnotationDenialSource]) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if msg := cmd.Annotations[cmdpolicy.AnnotationDenialMessage]; msg != "" {
|
||||||
|
return msg, true
|
||||||
|
}
|
||||||
|
return cmdpolicy.DefaultUnavailableMessage, true
|
||||||
|
}
|
||||||
|
|
||||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||||
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
||||||
// flags that are normally hidden in single-app mode (currently --profile)
|
// flags that are normally hidden in single-app mode (currently --profile)
|
||||||
// when rendering the root command's own help, so users discovering the CLI
|
// when rendering the root command's own help, so users discovering the CLI
|
||||||
// still see them at `lark-cli --help`.
|
// still see them at `lark-cli --help`.
|
||||||
func installTipsHelpFunc(root *cobra.Command) {
|
//
|
||||||
|
// skillContent is read lazily at help-render time (not captured up front) so
|
||||||
|
// the domain-guide pointer reflects the resolved skill tree -- the same
|
||||||
|
// f.SkillContent that `skills list`/`read` serve -- even though plugin skill
|
||||||
|
// customization is applied after this help func is installed.
|
||||||
|
func installTipsHelpFunc(root *cobra.Command, skillContent func() fs.FS) {
|
||||||
defaultHelp := root.HelpFunc()
|
defaultHelp := root.HelpFunc()
|
||||||
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||||
|
// Explicit help on a plugin-restricted command answers the same
|
||||||
|
// unavailable envelope as its RunE stub, not the original usage.
|
||||||
|
if msg, ok := unavailableHelpMessage(cmd); ok {
|
||||||
|
output.WriteTypedErrorEnvelope(cmd.ErrOrStderr(),
|
||||||
|
errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
if cmd == root {
|
if cmd == root {
|
||||||
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
|
// Force-show flags hidden by single-app mode; never a
|
||||||
|
// policy-retired one.
|
||||||
|
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden && !isPolicyGatedFlag(f) {
|
||||||
f.Hidden = false
|
f.Hidden = false
|
||||||
defer func() { f.Hidden = true }()
|
defer func() { f.Hidden = true }()
|
||||||
}
|
}
|
||||||
@@ -675,11 +750,15 @@ func installTipsHelpFunc(root *cobra.Command) {
|
|||||||
// Domain and method commands compose their agent guidance into Long lazily
|
// Domain and method commands compose their agent guidance into Long lazily
|
||||||
// here (shortcuts attach after service registration); both skip the generic
|
// here (shortcuts attach after service registration); both skip the generic
|
||||||
// bottom-of-help append below.
|
// bottom-of-help append below.
|
||||||
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
|
if service.PrepareDomainHelp(cmd, skillContent()) {
|
||||||
defaultHelp(cmd, args)
|
defaultHelp(cmd, args)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if service.PrepareMethodHelp(cmd) {
|
if service.PrepareMethodHelp(cmd, skillContent()) {
|
||||||
|
defaultHelp(cmd, args)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if service.PrepareShortcutHelp(cmd, skillContent()) {
|
||||||
defaultHelp(cmd, args)
|
defaultHelp(cmd, args)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import (
|
|||||||
"github.com/larksuite/cli/cmd/api"
|
"github.com/larksuite/cli/cmd/api"
|
||||||
"github.com/larksuite/cli/cmd/auth"
|
"github.com/larksuite/cli/cmd/auth"
|
||||||
"github.com/larksuite/cli/cmd/service"
|
"github.com/larksuite/cli/cmd/service"
|
||||||
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/envvars"
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
"github.com/larksuite/cli/internal/meta"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/skillscheck"
|
"github.com/larksuite/cli/internal/skillscheck"
|
||||||
"github.com/larksuite/cli/internal/update"
|
"github.com/larksuite/cli/internal/update"
|
||||||
@@ -103,6 +105,11 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||||
|
t.Helper()
|
||||||
|
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||||
rootCmd.SilenceErrors = true
|
rootCmd.SilenceErrors = true
|
||||||
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
|||||||
}
|
}
|
||||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||||
|
if catalog != nil {
|
||||||
|
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||||
|
} else {
|
||||||
service.RegisterServiceCommands(rootCmd, f)
|
service.RegisterServiceCommands(rootCmd, f)
|
||||||
|
}
|
||||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||||
pruneForStrictMode(rootCmd, mode)
|
pruneForStrictMode(rootCmd, mode)
|
||||||
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
|||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func strictModeFixtureCatalog() apicatalog.Catalog {
|
||||||
|
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
|
||||||
|
{
|
||||||
|
Name: "fixture",
|
||||||
|
ServicePath: "/open-apis/fixture/v1",
|
||||||
|
Resources: map[string]meta.Resource{
|
||||||
|
"things": {
|
||||||
|
Methods: map[string]meta.Method{
|
||||||
|
"create": {
|
||||||
|
Path: "things",
|
||||||
|
HTTPMethod: "POST",
|
||||||
|
AccessTokens: []meta.Token{meta.TokenTenant},
|
||||||
|
RequestBody: map[string]meta.Field{
|
||||||
|
"name": {Type: "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
t.Setenv(envvars.CliAppID, "")
|
t.Setenv(envvars.CliAppID, "")
|
||||||
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
|||||||
|
|
||||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
catalog := strictModeFixtureCatalog()
|
||||||
|
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||||
|
|
||||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
||||||
})
|
})
|
||||||
|
|
||||||
if code != output.ExitValidation {
|
if code != output.ExitValidation {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"io/fs"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -12,6 +13,10 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// nilSkills is the skill-content getter used by help-func tests that do
|
||||||
|
// not exercise the domain-guide pointer.
|
||||||
|
func nilSkills() fs.FS { return nil }
|
||||||
|
|
||||||
// rendersHelp runs the wrapped help func and returns stdout.
|
// rendersHelp runs the wrapped help func and returns stdout.
|
||||||
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -24,7 +29,7 @@ func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
|||||||
|
|
||||||
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
installTipsHelpFunc(root)
|
installTipsHelpFunc(root, nilSkills)
|
||||||
|
|
||||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||||
cmdutil.SetRisk(child, "high-risk-write")
|
cmdutil.SetRisk(child, "high-risk-write")
|
||||||
@@ -38,7 +43,7 @@ func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
|||||||
|
|
||||||
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
installTipsHelpFunc(root)
|
installTipsHelpFunc(root, nilSkills)
|
||||||
|
|
||||||
child := &cobra.Command{Use: "list", Short: "list items"}
|
child := &cobra.Command{Use: "list", Short: "list items"}
|
||||||
root.AddCommand(child)
|
root.AddCommand(child)
|
||||||
@@ -51,7 +56,7 @@ func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
|||||||
|
|
||||||
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
|
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
installTipsHelpFunc(root)
|
installTipsHelpFunc(root, nilSkills)
|
||||||
|
|
||||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||||
cmdutil.SetRisk(child, "high-risk-write")
|
cmdutil.SetRisk(child, "high-risk-write")
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
|
||||||
// It uses the embedded source so completion candidates match what `schema`
|
// It uses the same source as schema execution so completion candidates match
|
||||||
// execution can resolve (both overlay-free).
|
// what `schema` can resolve.
|
||||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
mode := f.ResolveStrictMode(cmd.Context())
|
mode := f.ResolveStrictMode(cmd.Context())
|
||||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||||
directive := cobra.ShellCompDirectiveNoFileComp
|
directive := cobra.ShellCompDirectiveNoFileComp
|
||||||
if noSpace {
|
if noSpace {
|
||||||
directive |= cobra.ShellCompDirectiveNoSpace
|
directive |= cobra.ShellCompDirectiveNoSpace
|
||||||
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
|
|||||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSchema resolves the path through the embedded catalog and renders the
|
// runSchema resolves the path through the schema catalog and renders the
|
||||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||||
// output shape — a single resolved method renders as one envelope object,
|
// output shape — a single resolved method renders as one envelope object,
|
||||||
// anything broader as an array — and maps resolve failures to hints.
|
// anything broader as an array — and maps resolve failures to hints.
|
||||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||||
catalog := registry.EmbeddedCatalog()
|
catalog := registry.SchemaCatalog()
|
||||||
|
if len(catalog.Services()) == 0 {
|
||||||
|
// No embedded metadata and the runtime fallback is empty too: offline
|
||||||
|
// with a cold cache, remote meta off, or an unwritable cache dir.
|
||||||
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
|
||||||
|
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
|
||||||
|
}
|
||||||
target, err := catalog.Resolve(parts)
|
target, err := catalog.Resolve(parts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolveError(err)
|
return resolveError(err)
|
||||||
|
|||||||
@@ -71,11 +71,18 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// domainHelpBase returns the description to seed domain help with — the
|
// domainHelpBase returns the description to seed domain help with — the
|
||||||
// hand-authored Long when present, else the Short — captured once into an
|
// hand-authored Long when present, else the Short.
|
||||||
// annotation so re-rendering reuses the pristine text instead of the
|
|
||||||
// already-augmented Long.
|
|
||||||
func domainHelpBase(cmd *cobra.Command) string {
|
func domainHelpBase(cmd *cobra.Command) string {
|
||||||
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
|
return captureHelpBase(cmd, domainBaseAnnotation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureHelpBase records a command's pristine lead text once — its
|
||||||
|
// hand-authored Long, or Short when Long is empty — into the given annotation,
|
||||||
|
// so lazy re-renders compose onto the original text instead of onto an
|
||||||
|
// already-augmented Long. This is what lets a shortcut's PostMount-authored
|
||||||
|
// Long survive: it becomes the base the affordance block is appended below.
|
||||||
|
func captureHelpBase(cmd *cobra.Command, key string) string {
|
||||||
|
if base, ok := cmd.Annotations[key]; ok {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
base := cmd.Long
|
base := cmd.Long
|
||||||
@@ -85,7 +92,7 @@ func domainHelpBase(cmd *cobra.Command) string {
|
|||||||
if cmd.Annotations == nil {
|
if cmd.Annotations == nil {
|
||||||
cmd.Annotations = map[string]string{}
|
cmd.Annotations = map[string]string{}
|
||||||
}
|
}
|
||||||
cmd.Annotations[domainBaseAnnotation] = base
|
cmd.Annotations[key] = base
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,12 +108,12 @@ func methodLong(description, schemaPath, paramsOnly string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
|
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
|
||||||
|
// The affordance overlay coordinates live in cmdmeta (shared with shortcuts).
|
||||||
const (
|
const (
|
||||||
affordanceServiceAnnotation = "affordance-service"
|
|
||||||
affordanceMethodAnnotation = "affordance-method"
|
|
||||||
schemaPathAnnotation = "method-schema-path"
|
schemaPathAnnotation = "method-schema-path"
|
||||||
paramsOnlyAnnotation = "method-params-only"
|
paramsOnlyAnnotation = "method-params-only"
|
||||||
domainBaseAnnotation = "affordance-domain-base"
|
domainBaseAnnotation = "affordance-domain-base"
|
||||||
|
shortcutBaseAnnotation = "affordance-shortcut-base"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||||
@@ -115,10 +122,7 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
|
|||||||
if cmd.Annotations == nil {
|
if cmd.Annotations == nil {
|
||||||
cmd.Annotations = map[string]string{}
|
cmd.Annotations = map[string]string{}
|
||||||
}
|
}
|
||||||
if service != "" && methodID != "" {
|
cmdmeta.SetAffordanceRef(cmd, service, methodID)
|
||||||
cmd.Annotations[affordanceServiceAnnotation] = service
|
|
||||||
cmd.Annotations[affordanceMethodAnnotation] = methodID
|
|
||||||
}
|
|
||||||
cmd.Annotations[schemaPathAnnotation] = schemaPath
|
cmd.Annotations[schemaPathAnnotation] = schemaPath
|
||||||
if paramsOnly != "" {
|
if paramsOnly != "" {
|
||||||
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
|
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
|
||||||
@@ -128,8 +132,11 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
|
|||||||
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
|
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
|
||||||
// guidance at the TOP (Risk, then the affordance block, then the schema
|
// guidance at the TOP (Risk, then the affordance block, then the schema
|
||||||
// pointer), returning false for non-method commands. The overlay is parsed
|
// pointer), returning false for non-method commands. The overlay is parsed
|
||||||
// here — only when help is rendered.
|
// here — only when help is rendered. skillFS (nil-safe) gates the related-skill
|
||||||
func PrepareMethodHelp(cmd *cobra.Command) bool {
|
// pointers: each is emitted only when it resolves in the skill tree (see
|
||||||
|
// affordance.SkillStatPath), so a typo or a build without embedded skills never
|
||||||
|
// prints a `skills read` that cannot be opened.
|
||||||
|
func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||||
ann := cmd.Annotations
|
ann := cmd.Annotations
|
||||||
if ann == nil {
|
if ann == nil {
|
||||||
return false
|
return false
|
||||||
@@ -141,22 +148,15 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
|
|||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(cmd.Short)
|
b.WriteString(cmd.Short)
|
||||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
writeRisk(&b, cmd)
|
||||||
// --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
|
var skills []string
|
||||||
if raw, ok := affordanceRaw(cmd); ok {
|
if raw, ok := affordanceRaw(cmd); ok {
|
||||||
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
|
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
|
||||||
|
if block := renderAffordanceValue(a); block != "" {
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
b.WriteString(block)
|
b.WriteString(block)
|
||||||
}
|
}
|
||||||
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
|
|
||||||
skills = a.Skills
|
skills = a.Skills
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,17 +164,95 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
|
|||||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||||
b.WriteString(ann[paramsOnlyAnnotation])
|
b.WriteString(ann[paramsOnlyAnnotation])
|
||||||
|
|
||||||
if len(skills) > 0 {
|
writeRelatedSkills(&b, skills, skillFS)
|
||||||
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()
|
cmd.Long = b.String()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
|
||||||
|
// overlay — the same top layout as method help (description, Risk, guidance
|
||||||
|
// block, related skills) minus the schema pointer, which shortcuts have none
|
||||||
|
// of. Returns false when the command is not a shortcut or carries no overlay
|
||||||
|
// entry, so shortcuts without guidance keep the default help plus the bottom
|
||||||
|
// risk/tips append.
|
||||||
|
//
|
||||||
|
// The lead is the command's pristine base (captureHelpBase): a shortcut that
|
||||||
|
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
|
||||||
|
// read the skill" directive) keeps it — the affordance block is appended below,
|
||||||
|
// never clobbering it.
|
||||||
|
//
|
||||||
|
// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The
|
||||||
|
// shortcut's declarative Tips (the Go Tips field) are only a fallback used when
|
||||||
|
// the overlay declares none; when the overlay has tips, the Go tips are dropped
|
||||||
|
// (replaced, not merged) so tips never render twice. Authoring a ### Tips block
|
||||||
|
// therefore silently retires that shortcut's Go Tips — consolidate into one.
|
||||||
|
func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||||
|
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
raw, ok := affordanceRaw(cmd)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(a.Tips) == 0 {
|
||||||
|
a.Tips = cmdutil.GetTips(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(captureHelpBase(cmd, shortcutBaseAnnotation))
|
||||||
|
writeRisk(&b, cmd)
|
||||||
|
if block := renderAffordanceValue(a); block != "" {
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(block)
|
||||||
|
}
|
||||||
|
writeRelatedSkills(&b, a.Skills, skillFS)
|
||||||
|
|
||||||
|
cmd.Long = b.String()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRisk appends the "Risk: <level>" line, warning agents not to self-approve
|
||||||
|
// high-risk-write commands. A no-op when the command has no risk annotation.
|
||||||
|
func writeRisk(b *strings.Builder, cmd *cobra.Command) {
|
||||||
|
level, ok := cmdutil.GetRisk(cmd)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// --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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRelatedSkills appends the "Related skills" block for the entries that
|
||||||
|
// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves,
|
||||||
|
// so help never prints a `skills read` pointer that cannot be opened.
|
||||||
|
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
|
||||||
|
if skillFS == nil || len(skills) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var avail []string
|
||||||
|
for _, s := range skills {
|
||||||
|
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
|
||||||
|
avail = append(avail, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(avail) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString("\n\nRelated skills (read for end-to-end usage):")
|
||||||
|
for _, s := range avail {
|
||||||
|
fmt.Fprintf(b, "\n lark-cli skills read %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// affordanceLookup is the overlay source; a package var so tests can inject.
|
// affordanceLookup is the overlay source; a package var so tests can inject.
|
||||||
var affordanceLookup = affordance.For
|
var affordanceLookup = affordance.For
|
||||||
|
|
||||||
@@ -189,12 +267,8 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
|
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
|
||||||
if cmd.Annotations == nil {
|
service, methodID, ok := cmdmeta.AffordanceRef(cmd)
|
||||||
return nil, false
|
if !ok {
|
||||||
}
|
|
||||||
service := cmd.Annotations[affordanceServiceAnnotation]
|
|
||||||
methodID := cmd.Annotations[affordanceMethodAnnotation]
|
|
||||||
if service == "" || methodID == "" {
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
return affordanceLookup(service, methodID)
|
return affordanceLookup(service, methodID)
|
||||||
@@ -207,7 +281,13 @@ func renderAffordance(m meta.Method) string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
return renderAffordanceValue(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderAffordanceValue renders an already-parsed affordance. Split from
|
||||||
|
// renderAffordance so callers can render a value they have adjusted first (e.g.
|
||||||
|
// a shortcut folding its declarative tips into an overlay that has none).
|
||||||
|
func renderAffordanceValue(a meta.Affordance) string {
|
||||||
var sections []string
|
var sections []string
|
||||||
bullets := func(title string, items []string) {
|
bullets := func(title string, items []string) {
|
||||||
var nonEmpty []string
|
var nonEmpty []string
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdmeta"
|
"github.com/larksuite/cli/internal/cmdmeta"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
@@ -70,8 +71,8 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
|
|||||||
t.Errorf("affordance must not be baked into Long (lazy):\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.
|
// The lookup ref is recorded so the help path can resolve it later.
|
||||||
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
|
if svc, method, ok := cmdmeta.AffordanceRef(cmd); !ok || svc != "im" || method != "messages.create" {
|
||||||
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
|
t.Errorf("affordance ref = %q/%q (ok=%v), want im/messages.create", svc, method, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@ func TestPrepareMethodHelp(t *testing.T) {
|
|||||||
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
||||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||||
|
|
||||||
if !PrepareMethodHelp(cmd) {
|
if !PrepareMethodHelp(cmd, nil) {
|
||||||
t.Fatal("PrepareMethodHelp returned false for a service-method command")
|
t.Fatal("PrepareMethodHelp returned false for a service-method command")
|
||||||
}
|
}
|
||||||
long := cmd.Long
|
long := cmd.Long
|
||||||
@@ -136,11 +137,133 @@ func TestPrepareMethodHelp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A non-service command (no schema-path annotation) is left untouched.
|
// A non-service command (no schema-path annotation) is left untouched.
|
||||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
|
if PrepareMethodHelp(&cobra.Command{Use: "plain"}, nil) {
|
||||||
t.Error("PrepareMethodHelp should return false for a non-service command")
|
t.Error("PrepareMethodHelp should return false for a non-service command")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same
|
||||||
|
// top layout as method help (no schema pointer), folding declarative tips when
|
||||||
|
// the overlay declares none, and leaves shortcuts without an overlay entry (and
|
||||||
|
// non-shortcut commands) for the default help path.
|
||||||
|
func TestPrepareShortcutHelp(t *testing.T) {
|
||||||
|
orig := affordanceLookup
|
||||||
|
t.Cleanup(func() { affordanceLookup = orig })
|
||||||
|
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
|
||||||
|
if service == "calendar" && methodID == "+create" {
|
||||||
|
return json.RawMessage(`{"use_when":["高层创建日程"],"skills":["lark-calendar"]}`), true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
sc := &cobra.Command{Use: "+create", Short: "Create an event"}
|
||||||
|
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||||
|
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
|
||||||
|
cmdutil.SetRisk(sc, "write")
|
||||||
|
cmdutil.SetTips(sc, []string{"start/end 收 ISO 8601"})
|
||||||
|
|
||||||
|
if !PrepareShortcutHelp(sc, nil) {
|
||||||
|
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Create an event", "Risk: write", "When to use:", "高层创建日程", "Tips:", "start/end 收 ISO 8601"} {
|
||||||
|
if !strings.Contains(sc.Long, want) {
|
||||||
|
t.Errorf("shortcut Long missing %q:\n%s", want, sc.Long)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(sc.Long, "Full parameter schema:") {
|
||||||
|
t.Errorf("shortcut Long must not carry a schema pointer:\n%s", sc.Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No overlay entry -> leave it for the default help path.
|
||||||
|
bare := &cobra.Command{Use: "+bare", Short: "x"}
|
||||||
|
cmdmeta.SetSource(bare, cmdmeta.SourceShortcut, false)
|
||||||
|
cmdmeta.SetAffordanceRef(bare, "calendar", "+bare")
|
||||||
|
if PrepareShortcutHelp(bare, nil) {
|
||||||
|
t.Error("PrepareShortcutHelp should return false when the shortcut has no overlay")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-shortcut source is ignored even with a ref.
|
||||||
|
notSc := &cobra.Command{Use: "create", Short: "x"}
|
||||||
|
cmdmeta.SetAffordanceRef(notSc, "calendar", "+create")
|
||||||
|
if PrepareShortcutHelp(notSc, nil) {
|
||||||
|
t.Error("PrepareShortcutHelp should return false for a non-shortcut command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Related-skill pointers are gated on existence: a skill that resolves in the
|
||||||
|
// skill FS renders, a typo is dropped (never print an unopenable `skills read`),
|
||||||
|
// and a nil skill FS suppresses the whole block.
|
||||||
|
func TestRelatedSkillsStatGating(t *testing.T) {
|
||||||
|
orig := affordanceLookup
|
||||||
|
t.Cleanup(func() { affordanceLookup = orig })
|
||||||
|
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||||
|
return json.RawMessage(`{"use_when":["x"],"skills":["lark-real","lark-typo","lark-real/references/deep.md","lark-real/references/missing.md"]}`), true
|
||||||
|
}
|
||||||
|
skillFS := fstest.MapFS{
|
||||||
|
"lark-real/SKILL.md": {Data: []byte("# real")},
|
||||||
|
"lark-real/references/deep.md": {Data: []byte("# deep")},
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||||
|
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "d"}
|
||||||
|
|
||||||
|
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||||
|
if !PrepareMethodHelp(cmd, skillFS) {
|
||||||
|
t.Fatal("PrepareMethodHelp returned false")
|
||||||
|
}
|
||||||
|
if !strings.Contains(cmd.Long, "skills read lark-real\n") {
|
||||||
|
t.Errorf("existing bare-name skill should render on its own line; got:\n%s", cmd.Long)
|
||||||
|
}
|
||||||
|
if strings.Contains(cmd.Long, "lark-typo") {
|
||||||
|
t.Errorf("nonexistent skill must be dropped, not printed as an unopenable pointer; got:\n%s", cmd.Long)
|
||||||
|
}
|
||||||
|
// A name/relpath reference to an existing file renders; a missing one drops.
|
||||||
|
if !strings.Contains(cmd.Long, "skills read lark-real/references/deep.md") {
|
||||||
|
t.Errorf("existing reference entry should render; got:\n%s", cmd.Long)
|
||||||
|
}
|
||||||
|
if strings.Contains(cmd.Long, "references/missing.md") {
|
||||||
|
t.Errorf("nonexistent reference must be dropped; got:\n%s", cmd.Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nil skill FS: the whole Related-skills block is suppressed.
|
||||||
|
bare := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||||
|
PrepareMethodHelp(bare, nil)
|
||||||
|
if strings.Contains(bare.Long, "Related skills") {
|
||||||
|
t.Errorf("nil skillFS should suppress the skills block; got:\n%s", bare.Long)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A shortcut that set a hand-authored Long (as the docs shortcuts do in
|
||||||
|
// PostMount) keeps it as the lead: the affordance block is appended below, not
|
||||||
|
// clobbered, and re-rendering does not double-append.
|
||||||
|
func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
|
||||||
|
orig := affordanceLookup
|
||||||
|
t.Cleanup(func() { affordanceLookup = orig })
|
||||||
|
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||||
|
return json.RawMessage(`{"use_when":["高层创建日程"]}`), true
|
||||||
|
}
|
||||||
|
|
||||||
|
const authored = "Custom docs help. AI agents MUST read the skill first."
|
||||||
|
sc := &cobra.Command{Use: "+create", Short: "Create", Long: authored}
|
||||||
|
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||||
|
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
|
||||||
|
|
||||||
|
if !PrepareShortcutHelp(sc, nil) {
|
||||||
|
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(sc.Long, authored) {
|
||||||
|
t.Errorf("hand-authored Long must lead, not be clobbered; got:\n%s", sc.Long)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sc.Long, "When to use:") {
|
||||||
|
t.Errorf("affordance block should be appended below the base; got:\n%s", sc.Long)
|
||||||
|
}
|
||||||
|
// Re-render must reuse the captured base, not append the block twice.
|
||||||
|
PrepareShortcutHelp(sc, nil)
|
||||||
|
if n := strings.Count(sc.Long, "When to use:"); n != 1 {
|
||||||
|
t.Errorf("affordance appended %d times across re-renders, want 1:\n%s", n, sc.Long)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
||||||
// shape PrepareDomainHelp expects.
|
// shape PrepareDomainHelp expects.
|
||||||
func domainCmd(short, long string) *cobra.Command {
|
func domainCmd(short, long string) *cobra.Command {
|
||||||
@@ -174,6 +297,26 @@ func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A service domain carries only a Short at help time; it seeds the base.
|
// A service domain carries only a Short at help time; it seeds the base.
|
||||||
|
// The domain-guide pointer is likewise gated: removing the domain's skill
|
||||||
|
// drops the pointer instead of leaving it dangling.
|
||||||
|
func TestPrepareDomainHelp_GatesGuidePointerOnFS(t *testing.T) {
|
||||||
|
present := domainCmd("Consume and manage real-time events", "")
|
||||||
|
if !PrepareDomainHelp(present, fstest.MapFS{"lark-event/SKILL.md": &fstest.MapFile{Data: []byte("x")}}) {
|
||||||
|
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||||
|
}
|
||||||
|
if !strings.Contains(present.Long, "lark-cli skills read lark-event") {
|
||||||
|
t.Errorf("skill present should emit the domain-guide pointer; got:\n%s", present.Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
removed := domainCmd("Consume and manage real-time events", "")
|
||||||
|
if !PrepareDomainHelp(removed, fstest.MapFS{}) {
|
||||||
|
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||||
|
}
|
||||||
|
if strings.Contains(removed.Long, "skills read lark-event") {
|
||||||
|
t.Errorf("removed skill must leave no domain-guide pointer; got:\n%s", removed.Long)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
||||||
dom := domainCmd("Message and group chat management", "")
|
dom := domainCmd("Message and group chat management", "")
|
||||||
if !PrepareDomainHelp(dom, nil) {
|
if !PrepareDomainHelp(dom, nil) {
|
||||||
|
|||||||
@@ -4,10 +4,14 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -1132,6 +1136,63 @@ func TestDetectFileFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseMultipartFilenames drives one service-method --file upload through the
|
||||||
|
// mock transport and returns a map of field name -> part filename parsed from
|
||||||
|
// the captured multipart body. Mirrors cmd/api's helper of the same name
|
||||||
|
// (inlined here rather than shared, since the two live in different packages)
|
||||||
|
// to give BuildFormdata's shared local-file fix a second real entry-point
|
||||||
|
// covering it.
|
||||||
|
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
ct := stub.CapturedHeaders.Get("Content-Type")
|
||||||
|
mediaType, params, err := mime.ParseMediaType(ct)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse Content-Type %q: %v", ct, err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(mediaType, "multipart/") {
|
||||||
|
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
|
||||||
|
}
|
||||||
|
filenames := map[string]string{}
|
||||||
|
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if fn := part.FileName(); fn != "" {
|
||||||
|
filenames[part.FormName()] = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filenames
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceMethod_FileUpload_PreservesFilename(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, testConfig)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
cmdutil.TestChdir(t, dir)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("fake-image"), 0600); err != nil {
|
||||||
|
t.Fatalf("write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stub := &httpmock.Stub{
|
||||||
|
URL: "/open-apis/im/v1/images",
|
||||||
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"image_key": "img_xxx"}},
|
||||||
|
}
|
||||||
|
reg.Register(stub)
|
||||||
|
|
||||||
|
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
||||||
|
cmd.SetArgs([]string{"--file", "photo.jpg", "--data", `{"image_type":"message"}`, "--as", "bot"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filenames := parseMultipartFilenames(t, stub)
|
||||||
|
if got := filenames["image"]; got != "photo.jpg" {
|
||||||
|
t.Fatalf("part filename for field %q = %q, want %q", "image", got, "photo.jpg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
|
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||||
|
|
||||||
|
|||||||
227
cmd/skill_customization_test.go
Normal file
227
cmd/skill_customization_test.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/extension/platform"
|
||||||
|
"github.com/larksuite/cli/internal/policystate"
|
||||||
|
"github.com/larksuite/cli/internal/skillcontent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withBaseSkills swaps the process-global embedded skill tree for the
|
||||||
|
// duration of a test, restoring it afterward.
|
||||||
|
func withBaseSkills(t *testing.T, files map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
base := fstest.MapFS{}
|
||||||
|
for p, content := range files {
|
||||||
|
base[p] = &fstest.MapFile{Data: []byte(content)}
|
||||||
|
}
|
||||||
|
saved := embeddedSkillContent
|
||||||
|
t.Cleanup(func() { embeddedSkillContent = saved })
|
||||||
|
embeddedSkillContent = base
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plugin's SkillsOverlay must reshape the tree the factory serves: skills
|
||||||
|
// list/read read f.SkillContent, so a resolved removal/overlay shows up here.
|
||||||
|
// (The --help pointers are gated on the same f.SkillContent; that gating is
|
||||||
|
// covered by the PrepareDomainHelp/PrepareMethodHelp tests in cmd/service.)
|
||||||
|
func TestBuildInternal_appliesPluginSkillsOverlay(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
platform.ResetForTesting()
|
||||||
|
t.Cleanup(platform.ResetForTesting)
|
||||||
|
|
||||||
|
withBaseSkills(t, map[string]string{
|
||||||
|
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
|
||||||
|
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
|
||||||
|
"lark-shared/SKILL.md": "---\ndescription: shared\n---\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
overlay := fstest.MapFS{
|
||||||
|
"lark-new/SKILL.md": &fstest.MapFile{Data: []byte("---\ndescription: new\n---\n")},
|
||||||
|
}
|
||||||
|
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||||
|
EmbeddedSkills(&platform.SkillsOverlay{
|
||||||
|
Remove: []string{"lark-shared"},
|
||||||
|
Overlay: overlay,
|
||||||
|
}).MustBuild())
|
||||||
|
|
||||||
|
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
if f.SkillContent == nil {
|
||||||
|
t.Fatal("f.SkillContent is nil after skill resolution")
|
||||||
|
}
|
||||||
|
|
||||||
|
skills, err := skillcontent.New(f.SkillContent).List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List: %v", err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, s := range skills {
|
||||||
|
names = append(names, s.Name)
|
||||||
|
}
|
||||||
|
if got := strings.Join(names, ","); got != "lark-a,lark-b,lark-new" {
|
||||||
|
t.Errorf("skills = %q, want lark-a,lark-b,lark-new (shared removed, new added)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two plugins each customizing skills must abort at dispatch with a
|
||||||
|
// structured envelope carrying reason_code multiple_skills_overlay_plugins, not
|
||||||
|
// silently fall back to the default tree.
|
||||||
|
func TestBuildInternal_multipleSkillPluginsGuard(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
platform.ResetForTesting()
|
||||||
|
t.Cleanup(platform.ResetForTesting)
|
||||||
|
|
||||||
|
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||||
|
|
||||||
|
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||||
|
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
|
||||||
|
platform.Register(platform.NewPlugin("globex", "1.0").
|
||||||
|
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
|
||||||
|
|
||||||
|
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
if reg != nil {
|
||||||
|
t.Errorf("skill conflict guard path should yield nil registry")
|
||||||
|
}
|
||||||
|
|
||||||
|
leaf := findRunnableLeaf(root)
|
||||||
|
if leaf == nil {
|
||||||
|
t.Fatal("no runnable leaf in command tree")
|
||||||
|
}
|
||||||
|
err := leaf.RunE(leaf, nil)
|
||||||
|
var verr *errs.ValidationError
|
||||||
|
if !errors.As(err, &verr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||||
|
}
|
||||||
|
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
|
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||||
|
}
|
||||||
|
if !strings.Contains(verr.Hint, "multiple_skills_overlay_plugins") {
|
||||||
|
t.Errorf("hint should surface reason_code multiple_skills_overlay_plugins, got %q", verr.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow keeps only the listed skills from the base — a CLI upgrade adding
|
||||||
|
// new embedded skills cannot widen an allow-listed build.
|
||||||
|
func TestBuildInternal_appliesAllowList(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
platform.ResetForTesting()
|
||||||
|
t.Cleanup(platform.ResetForTesting)
|
||||||
|
t.Cleanup(func() { policystate.ResetForTesting() })
|
||||||
|
|
||||||
|
withBaseSkills(t, map[string]string{
|
||||||
|
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
|
||||||
|
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
|
||||||
|
"lark-c/SKILL.md": "---\ndescription: c\n---\n",
|
||||||
|
})
|
||||||
|
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||||
|
EmbeddedSkills(&platform.SkillsOverlay{Allow: []string{"lark-a", "lark-c"}}).
|
||||||
|
MustBuild())
|
||||||
|
|
||||||
|
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
skills, err := skillcontent.New(f.SkillContent).List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List: %v", err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, s := range skills {
|
||||||
|
names = append(names, s.Name)
|
||||||
|
}
|
||||||
|
if got := strings.Join(names, ","); got != "lark-a,lark-c" {
|
||||||
|
t.Errorf("skills = %q, want lark-a,lark-c (allow-list)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plugin whose SkillsOverlay cannot compose (Remove naming a skill absent
|
||||||
|
// from the base) must abort with reason_code invalid_skills_overlay.
|
||||||
|
func TestBuildInternal_invalidSkillsOverlayGuard(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
platform.ResetForTesting()
|
||||||
|
t.Cleanup(platform.ResetForTesting)
|
||||||
|
|
||||||
|
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||||
|
|
||||||
|
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||||
|
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-does-not-exist"}}).MustBuild())
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||||
|
leaf := findRunnableLeaf(root)
|
||||||
|
if leaf == nil {
|
||||||
|
t.Fatal("no runnable leaf in command tree")
|
||||||
|
}
|
||||||
|
err := leaf.RunE(leaf, nil)
|
||||||
|
var verr *errs.ValidationError
|
||||||
|
if !errors.As(err, &verr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||||
|
}
|
||||||
|
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
|
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||||
|
}
|
||||||
|
if !strings.Contains(verr.Hint, "invalid_skills_overlay") {
|
||||||
|
t.Errorf("hint should surface reason_code invalid_skills_overlay, got %q", verr.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithEmbeddedSkills customizes skills for a caller that builds the tree directly
|
||||||
|
// (no plugin) — the build-option analogue of a plugin's EmbeddedSkills(...).
|
||||||
|
func TestBuildInternal_withSkillsOption(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
platform.ResetForTesting()
|
||||||
|
t.Cleanup(platform.ResetForTesting)
|
||||||
|
|
||||||
|
withBaseSkills(t, map[string]string{
|
||||||
|
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
|
||||||
|
"lark-shared/SKILL.md": "---\ndescription: shared\n---\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t),
|
||||||
|
WithEmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-shared"}}))
|
||||||
|
skills, err := skillcontent.New(f.SkillContent).List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List: %v", err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, s := range skills {
|
||||||
|
names = append(names, s.Name)
|
||||||
|
}
|
||||||
|
if got := strings.Join(names, ","); got != "lark-a" {
|
||||||
|
t.Errorf("skills = %q, want lark-a (shared removed via WithEmbeddedSkills)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithEmbeddedSkills and a plugin's EmbeddedSkills() are two owners of skill content; the
|
||||||
|
// single-owner rule aborts startup.
|
||||||
|
func TestBuildInternal_withSkillsPlusPluginConflicts(t *testing.T) {
|
||||||
|
tmpHome(t)
|
||||||
|
platform.ResetForTesting()
|
||||||
|
t.Cleanup(platform.ResetForTesting)
|
||||||
|
|
||||||
|
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||||
|
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||||
|
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
|
||||||
|
|
||||||
|
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t),
|
||||||
|
WithEmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}))
|
||||||
|
leaf := findRunnableLeaf(root)
|
||||||
|
if leaf == nil {
|
||||||
|
t.Fatal("no runnable leaf in command tree")
|
||||||
|
}
|
||||||
|
err := leaf.RunE(leaf, nil)
|
||||||
|
var verr *errs.ValidationError
|
||||||
|
if !errors.As(err, &verr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||||
|
}
|
||||||
|
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
|
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||||
|
}
|
||||||
|
if !strings.Contains(verr.Hint, "multiple_skills_overlay_plugins") {
|
||||||
|
t.Errorf("WithEmbeddedSkills + plugin should conflict; hint = %q", verr.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
|||||||
|
|
||||||
Detects the installation method automatically:
|
Detects the installation method automatically:
|
||||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||||
|
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||||
- manual/other: shows GitHub Releases download URL
|
- manual/other: shows GitHub Releases download URL
|
||||||
|
|
||||||
Use --json for structured output (for AI agents and scripts).
|
Use --json for structured output (for AI agents and scripts).
|
||||||
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
|
|||||||
if !detect.CanAutoUpdate() {
|
if !detect.CanAutoUpdate() {
|
||||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||||
}
|
}
|
||||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Output helpers ---
|
// --- Output helpers ---
|
||||||
@@ -226,12 +227,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
|||||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
|
if detect.Method == selfupdate.InstallPnpm {
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||||
|
} else {
|
||||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||||
|
}
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||||
|
pm := "npm"
|
||||||
|
install := updater.RunNpmInstall
|
||||||
|
if detect.Method == selfupdate.InstallPnpm {
|
||||||
|
pm = "pnpm"
|
||||||
|
install = updater.RunPnpmInstall
|
||||||
|
}
|
||||||
|
|
||||||
restore, err := updater.PrepareSelfReplace()
|
restore, err := updater.PrepareSelfReplace()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return reportError(opts, io, "update_error",
|
return reportError(opts, io, "update_error",
|
||||||
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
|
||||||
}
|
}
|
||||||
|
|
||||||
npmResult := updater.RunNpmInstall(latest)
|
npmResult := install(latest)
|
||||||
if npmResult.Err != nil {
|
if npmResult.Err != nil {
|
||||||
restore()
|
restore()
|
||||||
combined := npmResult.CombinedOutput()
|
combined := npmResult.CombinedOutput()
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false, "error": map[string]interface{}{
|
"ok": false, "error": map[string]interface{}{
|
||||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
|
||||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||||
"hint": permissionHint(combined),
|
"hint": permissionHint(combined, pm),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -263,7 +275,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||||
}
|
}
|
||||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||||
if hint := permissionHint(combined); hint != "" {
|
if hint := permissionHint(combined, pm); hint != "" {
|
||||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||||
}
|
}
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -274,7 +286,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
if err := updater.VerifyBinary(latest); err != nil {
|
if err := updater.VerifyBinary(latest); err != nil {
|
||||||
restore()
|
restore()
|
||||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||||
hint := verificationFailureHint(updater, latest)
|
hint := verificationFailureHint(updater, latest, pm)
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
@@ -304,23 +316,33 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
if skillsResult != nil {
|
if skillsResult != nil {
|
||||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
skillsPM := "npx"
|
||||||
|
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
|
||||||
|
skillsPM = "pnpm dlx"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
|
||||||
}
|
}
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func permissionHint(npmOutput string) string {
|
func permissionHint(pmOutput, pm string) string {
|
||||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
||||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
|
||||||
}
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if pm == "pnpm" {
|
||||||
|
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
|
||||||
|
}
|
||||||
|
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||||
|
}
|
||||||
|
|
||||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
|
||||||
if updater.CanRestorePreviousVersion() {
|
if updater.CanRestorePreviousVersion() {
|
||||||
return "the previous version has been restored"
|
return "the previous version has been restored"
|
||||||
}
|
}
|
||||||
|
if pm == "pnpm" {
|
||||||
|
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||||
|
}
|
||||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
|
||||||
|
// and fails the test if the npm install path is invoked.
|
||||||
|
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
|
||||||
|
t.Helper()
|
||||||
|
origNew := newUpdater
|
||||||
|
newUpdater = func() *selfupdate.Updater {
|
||||||
|
u := selfupdate.New()
|
||||||
|
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||||
|
u.PnpmInstallOverride = pnpmFn
|
||||||
|
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
|
||||||
|
t.Errorf("npm install must not be called for a pnpm install")
|
||||||
|
return &selfupdate.NpmResult{}
|
||||||
|
}
|
||||||
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
|
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||||
|
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
|
}
|
||||||
|
|
||||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||||
return func() *selfupdate.NpmResult {
|
return func() *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
@@ -81,6 +102,110 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, stdout, _ := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
)
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
|
||||||
|
t.Errorf("expected updated in output, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_Human(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, stderr := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
)
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
out := stderr.String()
|
||||||
|
if !strings.Contains(out, "via pnpm") {
|
||||||
|
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
|
||||||
|
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Successfully updated") {
|
||||||
|
t.Errorf("expected success message, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, stdout, _ := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
|
||||||
|
)
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error exit")
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
|
||||||
|
t.Errorf("expected failure envelope, got: %s", out)
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
|
||||||
|
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, stderr := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
out := stderr.String()
|
||||||
|
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
|
||||||
|
t.Errorf("expected pnpm manual reason, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "pnpm add -g") {
|
||||||
|
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeVersion(t *testing.T) {
|
func TestNormalizeVersion(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
|
|||||||
if !strings.Contains(out, "Successfully updated") {
|
if !strings.Contains(out, "Successfully updated") {
|
||||||
t.Errorf("expected success message in stderr, got: %s", out)
|
t.Errorf("expected success message in stderr, got: %s", out)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(out, "Updating skills via npx ...") {
|
||||||
|
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateForce_JSON(t *testing.T) {
|
func TestUpdateForce_JSON(t *testing.T) {
|
||||||
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
origOS := currentOS
|
origOS := currentOS
|
||||||
defer func() { currentOS = origOS }()
|
defer func() { currentOS = origOS }()
|
||||||
|
|
||||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
|
||||||
if !strings.Contains(hint, "npm global prefix") {
|
if !strings.Contains(hint, "npm global prefix") {
|
||||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||||
}
|
}
|
||||||
@@ -749,16 +877,25 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
|
||||||
|
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
|
||||||
|
if !strings.Contains(pnpmHint, "pnpm setup") {
|
||||||
|
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
|
||||||
|
}
|
||||||
|
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
|
||||||
|
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
|
||||||
|
}
|
||||||
|
|
||||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||||
currentOS = "windows"
|
currentOS = "windows"
|
||||||
hint = permissionHint("EACCES: permission denied")
|
hint = permissionHint("EACCES: permission denied", "npm")
|
||||||
if hint != "" {
|
if hint != "" {
|
||||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-EACCES error: always empty.
|
// Non-EACCES error: always empty.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
if got := permissionHint("some other error"); got != "" {
|
if got := permissionHint("some other error", "npm"); got != "" {
|
||||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,28 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
|
|||||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||||
`CategoryPolicy`.
|
`CategoryPolicy`.
|
||||||
|
|
||||||
|
### Success envelope (stdout)
|
||||||
|
|
||||||
|
For contrast: success responses render to **stdout** as an
|
||||||
|
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"identity": "user",
|
||||||
|
"data": { "guid": "e297d3d0-..." },
|
||||||
|
"meta": { "count": 1 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers must branch on `ok` (or the process exit code). The success
|
||||||
|
envelope has **no top-level `code` or `msg` field** — `code` exists only
|
||||||
|
inside `error`, where it is the upstream numeric code (invariant 4).
|
||||||
|
Wrappers that follow the raw OpenAPI convention and test `code == 0`
|
||||||
|
will misclassify every successful call as a failure, which is
|
||||||
|
especially dangerous around write commands (e.g. retrying a create that
|
||||||
|
already succeeded).
|
||||||
|
|
||||||
## Categories
|
## Categories
|
||||||
|
|
||||||
| Category | When | Exit | Typed struct |
|
| Category | When | Exit | Typed struct |
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const (
|
|||||||
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)
|
||||||
|
SubtypeCommandUnavailable Subtype = "command_unavailable" // command not included in this build (integrator-restricted distribution); absent, not gated
|
||||||
)
|
)
|
||||||
|
|
||||||
// CategoryAuthentication subtypes
|
// CategoryAuthentication subtypes
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ You should see `audit` in the plugin list.
|
|||||||
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
|
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
|
||||||
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
|
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
|
||||||
| `Restrict(Rule)` | Bootstrap-time, ≥1 per plugin | Denies whole subtrees |
|
| `Restrict(Rule)` | Bootstrap-time, ≥1 per plugin | Denies whole subtrees |
|
||||||
|
| `EmbeddedSkills(SkillsOverlay)` | Bootstrap-time, ≤1 per plugin | No (customizes embedded skills) |
|
||||||
|
|
||||||
### Plugin lifecycle
|
### Plugin lifecycle
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ sequenceDiagram
|
|||||||
Host->>SDK: InstallAll()
|
Host->>SDK: InstallAll()
|
||||||
SDK->>Plugin: Capabilities()
|
SDK->>Plugin: Capabilities()
|
||||||
SDK->>Plugin: Install(Registrar)
|
SDK->>Plugin: Install(Registrar)
|
||||||
Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown)
|
Plugin->>SDK: Observe / Wrap / Restrict / EmbeddedSkills / On(Startup,Shutdown)
|
||||||
SDK->>Plugin: On(Startup) fire
|
SDK->>Plugin: On(Startup) fire
|
||||||
|
|
||||||
Note over Host,Plugin: Each command dispatch
|
Note over Host,Plugin: Each command dispatch
|
||||||
@@ -113,6 +114,35 @@ the rejected dispatch.
|
|||||||
widen another's policy). YAML policy at `~/.lark-cli/policy.yml` (which
|
widen another's policy). YAML policy at `~/.lark-cli/policy.yml` (which
|
||||||
may itself list several rules under `rules:`) is shadowed by any plugin
|
may itself list several rules under `rules:`) is shadowed by any plugin
|
||||||
Restrict.
|
Restrict.
|
||||||
|
- A plugin may call `EmbeddedSkills()` at most once to customize the embedded
|
||||||
|
skill tree — `Allow` keeps only the listed skills (the allow-list
|
||||||
|
counterpart of `Rule.Allow`, so a CLI upgrade cannot widen the build;
|
||||||
|
`Remove` wins over `Allow`, and `Overlay` entries are exempt), `Remove`
|
||||||
|
drops skills, `Overlay` adds/replaces ones, or swap the whole `Base` —
|
||||||
|
layered over the CLI default. Unlike `Restrict()`
|
||||||
|
it is NOT a security boundary and does not imply `FailClosed`: it
|
||||||
|
shapes fail-open guidance content. Removing a skill only drops its
|
||||||
|
`skills read` / `--help` guidance — it does NOT disable the matching
|
||||||
|
commands (use `Restrict()` for that). Only ONE plugin per binary may
|
||||||
|
contribute a `SkillsOverlay`; two DISTINCT plugins is a deliberate
|
||||||
|
`multiple_skills_overlay_plugins` error.
|
||||||
|
- A command denied by a **plugin** Rule presents as absent, not as
|
||||||
|
forbidden: it leaves `--help` and completion, explicit help on it is
|
||||||
|
intercepted, and invoking it answers `subtype=command_unavailable`
|
||||||
|
with no policy vocabulary and no recovery hint. Set
|
||||||
|
`Rule.DeniedMessage` to replace the default
|
||||||
|
"command not included in this build" with your product's own wording.
|
||||||
|
yaml-source denials keep the classic `command_denied` presentation —
|
||||||
|
the user owns that policy and needs to see how to adjust it. Denying a
|
||||||
|
whole domain also retires what points at it: `--profile` (profile
|
||||||
|
domain) hides and rejects use, the root-help skills footer (skills),
|
||||||
|
update notices (update), and the `auth login` recovery hint (auth)
|
||||||
|
stop rendering.
|
||||||
|
- `config policy show` / `config plugins show` stay executable under any
|
||||||
|
plugin policy (hidden from help when their domain is denied) so an
|
||||||
|
operator can still inspect the rule that locked the build. An
|
||||||
|
integrator shipping a fully-managed distribution opts out with
|
||||||
|
`HideDiagnostics()` — requires `Restrict()` on the same plugin.
|
||||||
- The `Wrap` factory runs **once per command dispatch**, not at
|
- The `Wrap` factory runs **once per command dispatch**, not at
|
||||||
install time. Long-lived state (clients, caches, metrics counters)
|
install time. Long-lived state (clients, caches, metrics counters)
|
||||||
must live on the Plugin struct or in package-level variables.
|
must live on the Plugin struct or in package-level variables.
|
||||||
@@ -153,6 +183,8 @@ messages are localised and may change between releases.
|
|||||||
| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
|
| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
|
||||||
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
|
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
|
||||||
| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes |
|
| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes |
|
||||||
|
| `invalid_skills_overlay` | Staging fault (`EmbeddedSkills(nil)` / second call in one plugin) honours FailurePolicy; a `SkillsOverlay` that can't compose (`Remove` names a skill absent from the base, `Overlay` entry lacks `SKILL.md`) always aborts via a fatal dispatch guard | Mixed — see left |
|
||||||
|
| `multiple_skills_overlay_plugins` | Two or more DISTINCT plugins each contributed a `SkillsOverlay` (only one may own skill content) | No — always aborts (dispatch guard) |
|
||||||
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
|
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
|
||||||
| `install_panic` | `Plugin.Install` panicked | Yes |
|
| `install_panic` | `Plugin.Install` panicked | Yes |
|
||||||
|
|
||||||
@@ -187,8 +219,8 @@ should additionally check `detail.layer == "policy"`.
|
|||||||
- [Runnable example: audit observer](./examples/audit-observer/)
|
- [Runnable example: audit observer](./examples/audit-observer/)
|
||||||
- [Runnable example: read-only policy](./examples/readonly-policy/)
|
- [Runnable example: read-only policy](./examples/readonly-policy/)
|
||||||
- Builder API: see [`builder.go`](./builder.go) for the full DSL
|
- Builder API: see [`builder.go`](./builder.go) for the full DSL
|
||||||
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `FailOpen`/`FailClosed`,
|
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `EmbeddedSkills`,
|
||||||
`MustBuild`).
|
`HideDiagnostics`, `FailOpen`/`FailClosed`, `MustBuild`).
|
||||||
- Inventory diagnostic: run `lark-cli config plugins show` after
|
- Inventory diagnostic: run `lark-cli config plugins show` after
|
||||||
installing your plugin to see hooks/rules attributed to your plugin
|
installing your plugin to see hooks/rules attributed to your plugin
|
||||||
name.
|
name.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type Builder struct {
|
|||||||
|
|
||||||
actions []func(Registrar)
|
actions []func(Registrar)
|
||||||
rules []*Rule
|
rules []*Rule
|
||||||
|
skillsOverlay *SkillsOverlay
|
||||||
|
|
||||||
hookNames map[string]bool
|
hookNames map[string]bool
|
||||||
errs []error
|
errs []error
|
||||||
@@ -81,6 +82,15 @@ func (b *Builder) FailClosed() *Builder {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HideDiagnostics retires the policy self-inspection commands
|
||||||
|
// (`config policy show`, `config plugins show`), which otherwise stay
|
||||||
|
// executable under a plugin policy as the operator's escape hatch.
|
||||||
|
// Requires Restrict() on the same plugin; Build fails otherwise.
|
||||||
|
func (b *Builder) HideDiagnostics() *Builder {
|
||||||
|
b.caps.HideDiagnostics = true
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
// Observer registers an Observer. Multiple calls accumulate.
|
// Observer registers an Observer. Multiple calls accumulate.
|
||||||
func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder {
|
func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder {
|
||||||
if !b.validateHookName(hookName, "observer") {
|
if !b.validateHookName(hookName, "observer") {
|
||||||
@@ -145,6 +155,35 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EmbeddedSkills contributes a SkillsOverlay (see SkillsOverlay)
|
||||||
|
// customizing the CLI's embedded skill content. Unlike Restrict it does
|
||||||
|
// NOT imply FailClosed: skill customization is guidance content, not a
|
||||||
|
// security boundary. A plugin owns at most one SkillsOverlay, so calling
|
||||||
|
// EmbeddedSkills more than once is a build error.
|
||||||
|
func (b *Builder) EmbeddedSkills(spec *SkillsOverlay) *Builder {
|
||||||
|
if spec == nil {
|
||||||
|
b.errs = append(b.errs, errors.New("EmbeddedSkills(nil): spec must not be nil"))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
if b.skillsOverlay != nil {
|
||||||
|
b.errs = append(b.errs, errors.New("EmbeddedSkills() called more than once; a plugin owns at most one SkillsOverlay"))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
b.skillsOverlay = cloneSkillsOverlay(spec)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneSkillsOverlay snapshots the caller's spec so a later mutation of the
|
||||||
|
// same *SkillsOverlay cannot alter the staged copy. The Remove slice is
|
||||||
|
// copied; Overlay/Base are fs.FS handles retained by reference (an fs.FS
|
||||||
|
// is a read-only view, not caller-mutable state).
|
||||||
|
func cloneSkillsOverlay(spec *SkillsOverlay) *SkillsOverlay {
|
||||||
|
cp := *spec
|
||||||
|
cp.Allow = append([]string(nil), spec.Allow...)
|
||||||
|
cp.Remove = append([]string(nil), spec.Remove...)
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
|
||||||
// Build returns the configured Plugin, or an error if any builder
|
// Build returns the configured Plugin, or an error if any builder
|
||||||
// step found a fault. MustBuild panics on the same error.
|
// step found a fault. MustBuild panics on the same error.
|
||||||
//
|
//
|
||||||
@@ -155,6 +194,10 @@ func (b *Builder) Build() (Plugin, error) {
|
|||||||
b.errs = append(b.errs, errors.New(
|
b.errs = append(b.errs, errors.New(
|
||||||
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
|
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
|
||||||
}
|
}
|
||||||
|
if b.caps.HideDiagnostics && len(b.rules) == 0 {
|
||||||
|
b.errs = append(b.errs, errors.New(
|
||||||
|
"HideDiagnostics() requires Restrict(): there is no integrator policy to hide"))
|
||||||
|
}
|
||||||
if len(b.errs) > 0 {
|
if len(b.errs) > 0 {
|
||||||
return nil, errors.Join(b.errs...)
|
return nil, errors.Join(b.errs...)
|
||||||
}
|
}
|
||||||
@@ -164,6 +207,7 @@ func (b *Builder) Build() (Plugin, error) {
|
|||||||
caps: b.caps,
|
caps: b.caps,
|
||||||
actions: b.actions,
|
actions: b.actions,
|
||||||
rules: b.rules,
|
rules: b.rules,
|
||||||
|
skillsOverlay: b.skillsOverlay,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +251,7 @@ type builtPlugin struct {
|
|||||||
caps Capabilities
|
caps Capabilities
|
||||||
actions []func(Registrar)
|
actions []func(Registrar)
|
||||||
rules []*Rule
|
rules []*Rule
|
||||||
|
skillsOverlay *SkillsOverlay
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *builtPlugin) Name() string { return p.name }
|
func (p *builtPlugin) Name() string { return p.name }
|
||||||
@@ -216,6 +261,9 @@ func (p *builtPlugin) Install(r Registrar) error {
|
|||||||
for _, rule := range p.rules {
|
for _, rule := range p.rules {
|
||||||
r.Restrict(rule)
|
r.Restrict(rule)
|
||||||
}
|
}
|
||||||
|
if p.skillsOverlay != nil {
|
||||||
|
r.EmbeddedSkills(p.skillsOverlay)
|
||||||
|
}
|
||||||
for _, action := range p.actions {
|
for _, action := range p.actions {
|
||||||
action(r)
|
action(r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type recorder struct {
|
|||||||
lifecycles int
|
lifecycles int
|
||||||
rule *platform.Rule // last rule (existing single-rule assertions)
|
rule *platform.Rule // last rule (existing single-rule assertions)
|
||||||
rules []*platform.Rule // every rule, in Restrict order
|
rules []*platform.Rule // every rule, in Restrict order
|
||||||
|
skillsOverlay *platform.SkillsOverlay
|
||||||
|
skillCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
|
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
|
||||||
@@ -30,6 +32,10 @@ func (r *recorder) Restrict(rule *platform.Rule) {
|
|||||||
r.rule = rule
|
r.rule = rule
|
||||||
r.rules = append(r.rules, rule)
|
r.rules = append(r.rules, rule)
|
||||||
}
|
}
|
||||||
|
func (r *recorder) EmbeddedSkills(spec *platform.SkillsOverlay) {
|
||||||
|
r.skillsOverlay = spec
|
||||||
|
r.skillCalls++
|
||||||
|
}
|
||||||
|
|
||||||
// Restrict must snapshot each rule: a caller that reuses and mutates the
|
// Restrict must snapshot each rule: a caller that reuses and mutates the
|
||||||
// same *Rule object across two Restrict calls must still get two distinct
|
// same *Rule object across two Restrict calls must still get two distinct
|
||||||
@@ -211,3 +217,78 @@ func TestBuilder_failOpenThenRestrictOK(t *testing.T) {
|
|||||||
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
|
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EmbeddedSkills() must snapshot Remove: a caller that mutates the same slice
|
||||||
|
// after the call must still get the value staged at call time.
|
||||||
|
func TestBuilder_skillsInstalledAndCloned(t *testing.T) {
|
||||||
|
remove := []string{"lark-shared"}
|
||||||
|
b := platform.NewPlugin("p", "0").EmbeddedSkills(&platform.SkillsOverlay{Remove: remove})
|
||||||
|
remove[0] = "mutated"
|
||||||
|
|
||||||
|
p, err := b.Build()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build: %v", err)
|
||||||
|
}
|
||||||
|
r := &recorder{}
|
||||||
|
if err := p.Install(r); err != nil {
|
||||||
|
t.Fatalf("Install: %v", err)
|
||||||
|
}
|
||||||
|
if r.skillCalls != 1 {
|
||||||
|
t.Fatalf("Skills calls = %d, want 1", r.skillCalls)
|
||||||
|
}
|
||||||
|
if r.skillsOverlay == nil || len(r.skillsOverlay.Remove) != 1 || r.skillsOverlay.Remove[0] != "lark-shared" {
|
||||||
|
t.Errorf("staged Remove leaked later mutation: %+v", r.skillsOverlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilder_skillsNilRejected(t *testing.T) {
|
||||||
|
_, err := platform.NewPlugin("p", "0").EmbeddedSkills(nil).Build()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("EmbeddedSkills(nil) must produce error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilder_skillsTwiceRejected(t *testing.T) {
|
||||||
|
_, err := platform.NewPlugin("p", "0").
|
||||||
|
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).
|
||||||
|
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}}).
|
||||||
|
Build()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("calling EmbeddedSkills() twice must produce error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbeddedSkills() customizes guidance content, not a security boundary, so
|
||||||
|
// unlike Restrict() it must NOT force FailClosed.
|
||||||
|
func TestBuilder_skillsDoesNotForceFailClosed(t *testing.T) {
|
||||||
|
p, err := platform.NewPlugin("p", "0").EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).Build()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build: %v", err)
|
||||||
|
}
|
||||||
|
caps := p.Capabilities()
|
||||||
|
if caps.Restricts {
|
||||||
|
t.Error("EmbeddedSkills() must not set Restricts")
|
||||||
|
}
|
||||||
|
if caps.FailurePolicy != platform.FailOpen {
|
||||||
|
t.Errorf("FailurePolicy = %v, want FailOpen (default)", caps.FailurePolicy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HideDiagnostics only makes sense alongside Restrict: without a rule
|
||||||
|
// there is no integrator policy whose inspection could be hidden.
|
||||||
|
func TestBuilder_hideDiagnosticsRequiresRestrict(t *testing.T) {
|
||||||
|
_, err := platform.NewPlugin("p", "0").HideDiagnostics().Build()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("HideDiagnostics() without Restrict() must fail Build")
|
||||||
|
}
|
||||||
|
p, err := platform.NewPlugin("p", "0").
|
||||||
|
Restrict(&platform.Rule{Deny: []string{"config/**"}}).
|
||||||
|
HideDiagnostics().
|
||||||
|
Build()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HideDiagnostics()+Restrict() must build: %v", err)
|
||||||
|
}
|
||||||
|
if !p.Capabilities().HideDiagnostics {
|
||||||
|
t.Error("Capabilities.HideDiagnostics must be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,4 +47,9 @@ type Capabilities struct {
|
|||||||
// constants above; the framework requires FailClosed whenever
|
// constants above; the framework requires FailClosed whenever
|
||||||
// Restricts=true.
|
// Restricts=true.
|
||||||
FailurePolicy FailurePolicy
|
FailurePolicy FailurePolicy
|
||||||
|
|
||||||
|
// HideDiagnostics retires the policy self-inspection commands
|
||||||
|
// (`config policy show`, `config plugins show`) from the build.
|
||||||
|
// Requires Restricts=true; the install fails otherwise.
|
||||||
|
HideDiagnostics bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,4 +35,18 @@ type Registrar interface {
|
|||||||
// Plugin rules take precedence over the yaml source; two distinct
|
// Plugin rules take precedence over the yaml source; two distinct
|
||||||
// plugins both calling Restrict abort startup.
|
// plugins both calling Restrict abort startup.
|
||||||
Restrict(r *Rule)
|
Restrict(r *Rule)
|
||||||
|
|
||||||
|
// Skills contributes a SkillsOverlay customizing the CLI's embedded skill
|
||||||
|
// content (see SkillsOverlay). Skill content has a single owner: a second
|
||||||
|
// customizing plugin, or a SkillsOverlay that cannot compose, aborts
|
||||||
|
// startup unconditionally. A malformed call inside one plugin --
|
||||||
|
// EmbeddedSkills(nil) or a second EmbeddedSkills() -- is a staging fault handled like
|
||||||
|
// any Install error (FailClosed aborts, FailOpen skips); the Builder
|
||||||
|
// rejects it earlier, at MustBuild.
|
||||||
|
//
|
||||||
|
// Unlike Restrict, EmbeddedSkills is not a security boundary -- it shapes
|
||||||
|
// fail-open guidance content. Removing a skill drops its guidance but
|
||||||
|
// does not disable any command; use Restrict to actually block a
|
||||||
|
// command.
|
||||||
|
EmbeddedSkills(spec *SkillsOverlay)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ package platform
|
|||||||
// Rule is the declarative policy rule data structure. yaml files and
|
// Rule is the declarative policy rule data structure. yaml files and
|
||||||
// Plugin.Restrict() both produce the same Rule.
|
// Plugin.Restrict() both produce the same Rule.
|
||||||
//
|
//
|
||||||
// At any moment there is at most one effective Rule -- the resolver decides
|
// At any moment there is at most one effective SOURCE of rules -- the
|
||||||
// which source wins (Plugin > yaml > none). This package only defines the
|
// resolver decides which wins (Plugin > yaml > none); the winning source
|
||||||
|
// may contribute several scoped rules. This package only defines the
|
||||||
// shape; selection lives in internal/cmdpolicy.
|
// shape; selection lives in internal/cmdpolicy.
|
||||||
//
|
//
|
||||||
// The four filter fields are joined by AND. See the engine's Evaluate for
|
// The four filter fields are joined by AND. See the engine's Evaluate for
|
||||||
@@ -57,4 +58,11 @@ type Rule struct {
|
|||||||
// No yaml tag: yaml decoding lives in internal/cmdpolicy/yaml so
|
// No yaml tag: yaml decoding lives in internal/cmdpolicy/yaml so
|
||||||
// platform stays free of a yaml library dependency.
|
// platform stays free of a yaml library dependency.
|
||||||
AllowUnannotated bool `json:"allow_unannotated,omitempty"`
|
AllowUnannotated bool `json:"allow_unannotated,omitempty"`
|
||||||
|
|
||||||
|
// DeniedMessage replaces the default "command not included in this
|
||||||
|
// build" message for plugin-denied commands. The message is
|
||||||
|
// build-level: the first non-empty DeniedMessage across the owning
|
||||||
|
// plugin's rules applies to every denial, so declare it once. yaml
|
||||||
|
// rules ignore it (they keep the command-denied presentation).
|
||||||
|
DeniedMessage string `json:"denied_message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
49
extension/platform/skillsoverlay.go
Normal file
49
extension/platform/skillsoverlay.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package platform
|
||||||
|
|
||||||
|
import "io/fs"
|
||||||
|
|
||||||
|
// SkillsOverlay declares how a plugin customizes the CLI's embedded
|
||||||
|
// skill content, contributed via Builder.EmbeddedSkills. At most one
|
||||||
|
// source may own skill content; two customizing plugins abort startup.
|
||||||
|
//
|
||||||
|
// Allow / Remove mirror Rule's Allow / Deny: an allow-list keeps only
|
||||||
|
// what it names, a remove-list drops what it names, and Remove wins
|
||||||
|
// over Allow. Composition order is fixed: Base (or the CLI default) ->
|
||||||
|
// Allow -> Remove -> Overlay, a same-named skill resolving to Overlay.
|
||||||
|
//
|
||||||
|
// Skills are addressed by exact name (a directory carrying SKILL.md,
|
||||||
|
// e.g. "lark-doc"), not by command path and not by glob — the skill
|
||||||
|
// list is flat, so misspellings abort startup instead of silently
|
||||||
|
// matching nothing. Removing a skill only drops its guidance; it does
|
||||||
|
// not disable any command (use Restrict for that).
|
||||||
|
type SkillsOverlay struct {
|
||||||
|
// Allow, when non-empty, keeps only these skills (by name) from the
|
||||||
|
// base tree — the allow-list counterpart of Rule.Allow. Skills the
|
||||||
|
// CLI adds in future versions stay out of the build until listed
|
||||||
|
// here, which a Remove-only spec cannot guarantee. A name not
|
||||||
|
// present in the base aborts startup. Overlay entries are exempt:
|
||||||
|
// content the integrator explicitly ships needs no allow-listing.
|
||||||
|
Allow []string
|
||||||
|
|
||||||
|
// Remove hides these skills, by name (e.g. "lark-shared"), from the
|
||||||
|
// base tree; it wins over Allow, mirroring Rule's Deny-over-Allow. A
|
||||||
|
// name not present in the base aborts startup rather than being
|
||||||
|
// silently ignored.
|
||||||
|
Remove []string
|
||||||
|
|
||||||
|
// Overlay contributes skills laid over the base: a same-named skill
|
||||||
|
// replaces the base's entirely, a new name adds one. It is rooted at
|
||||||
|
// the skill list (entries like "my-skill/SKILL.md"); each top-level
|
||||||
|
// entry must be a "<name>/" directory containing SKILL.md. Any fs.FS
|
||||||
|
// works (embed.FS, os.DirFS, fstest.MapFS); embed.FS is not required.
|
||||||
|
Overlay fs.FS
|
||||||
|
|
||||||
|
// Base replaces the entire base skill tree instead of layering over
|
||||||
|
// the CLI default. nil keeps the CLI default. Rare: most integrators
|
||||||
|
// leave it nil and use Remove/Overlay so unchanged skills follow the
|
||||||
|
// CLI version with no copy to maintain.
|
||||||
|
Base fs.FS
|
||||||
|
}
|
||||||
2
go.mod
2
go.mod
@@ -10,7 +10,7 @@ require (
|
|||||||
github.com/gofrs/flock v0.8.1
|
github.com/gofrs/flock v0.8.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/itchyny/gojq v0.12.17
|
github.com/itchyny/gojq v0.12.17
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
|
github.com/larksuite/oapi-sdk-go/v3 v3.7.2
|
||||||
github.com/sergi/go-diff v1.4.0
|
github.com/sergi/go-diff v1.4.0
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/smartystreets/goconvey v1.8.1
|
github.com/smartystreets/goconvey v1.8.1
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -79,8 +79,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
|
|||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
github.com/larksuite/oapi-sdk-go/v3 v3.7.2 h1:SCIcXHRmtpQbiaZgDTDi1NYNCzrusi7ePJBR9uKoduE=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
github.com/larksuite/oapi-sdk-go/v3 v3.7.2/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
|||||||
@@ -77,20 +77,15 @@ func loadService(service string) map[string]json.RawMessage {
|
|||||||
// space→dot fallback covers domains where the two already coincide.
|
// space→dot fallback covers domains where the two already coincide.
|
||||||
func commandFormResolver(service string) func(string) string {
|
func commandFormResolver(service string) func(string) string {
|
||||||
byForm := map[string]string{}
|
byForm := map[string]string{}
|
||||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
if svc, ok := registry.SchemaCatalog().Service(service); ok {
|
||||||
if svc.Name != service {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
}
|
||||||
return func(h string) string {
|
return func(h string) string {
|
||||||
h = strings.TrimSpace(h)
|
if id, ok := byForm[strings.TrimSpace(h)]; ok {
|
||||||
if id, ok := byForm[h]; ok {
|
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
return strings.ReplaceAll(h, " ", ".")
|
return headingToKey(h) // one home for the shortcut/method key convention
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/meta"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fixtureMD is a minimal affordance source: two methods, each with a lead
|
// fixtureMD is a minimal affordance source: two methods, each with a lead
|
||||||
@@ -84,3 +86,38 @@ func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
|
|||||||
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
|
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ### Skills section merges with the domain `> skill:` default: domain
|
||||||
|
// first, then per-command entries, de-duplicated. A command with no ### Skills
|
||||||
|
// still inherits the domain default.
|
||||||
|
func TestParseDomainMD_SkillsMerge(t *testing.T) {
|
||||||
|
md := "# d\n> skill: lark-d\n\n" +
|
||||||
|
"## foo\ndoes foo.\n\n### Skills\n- lark-workflow\n- lark-d\n\n" + // lark-d duplicates the domain default
|
||||||
|
"## bar\ndoes bar.\n"
|
||||||
|
got := parseDomainMD([]byte(md), nil)
|
||||||
|
|
||||||
|
if a := got["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" {
|
||||||
|
t.Errorf("foo skills = %v, want [lark-d lark-workflow] (domain first, deduped)", a.Skills)
|
||||||
|
}
|
||||||
|
if a := got["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
|
||||||
|
t.Errorf("bar skills = %v, want [lark-d] (domain default inherited)", a.Skills)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A +-prefixed shortcut heading keys verbatim (no space->dot folding), so it
|
||||||
|
// matches the shortcut command as mounted.
|
||||||
|
func TestParseDomainMD_ShortcutHeadingVerbatim(t *testing.T) {
|
||||||
|
md := "# d\n\n## +create\ncreate via shortcut.\n"
|
||||||
|
got := parseDomainMD([]byte(md), nil)
|
||||||
|
if _, ok := got["+create"]; !ok {
|
||||||
|
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysOf(m map[string]meta.Affordance) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
|
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
|
||||||
// ### Tips -> tips
|
// ### Tips -> tips
|
||||||
// ### Examples -> examples: **description** + a ```fenced``` command
|
// ### Examples -> examples: **description** + a ```fenced``` command
|
||||||
|
// ### Skills -> skills: bullet skill names, added to the domain default
|
||||||
// ### <other> -> extensions[] (custom section, flows through verbatim)
|
// ### <other> -> extensions[] (custom section, flows through verbatim)
|
||||||
// [[cmd]] -> a command reference, rendered as `cmd`
|
// [[cmd]] -> a command reference, rendered as `cmd`
|
||||||
//
|
//
|
||||||
@@ -34,16 +35,56 @@ var standardSection = map[string]string{
|
|||||||
"Prerequisites": "prerequisites",
|
"Prerequisites": "prerequisites",
|
||||||
"Tips": "tips",
|
"Tips": "tips",
|
||||||
"Examples": "examples",
|
"Examples": "examples",
|
||||||
|
"Skills": "skills",
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeSkills returns the domain-default skill followed by a command's own skill
|
||||||
|
// entries, de-duplicated in author order and empties dropped. Backticks (left by
|
||||||
|
// the shared bullet parse) are stripped so each entry is a bare skill name.
|
||||||
|
func mergeSkills(domain string, extra []string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
add := func(s string) {
|
||||||
|
s = strings.Trim(strings.TrimSpace(s), "`")
|
||||||
|
if s == "" || seen[s] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[s] = true
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
add(domain)
|
||||||
|
for _, s := range extra {
|
||||||
|
add(s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
|
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
|
||||||
|
|
||||||
|
// SkillStatPath maps a `### Skills` entry to the path (relative to the skill
|
||||||
|
// tree) whose existence gates it: a bare skill name resolves to its SKILL.md,
|
||||||
|
// while an entry containing a slash is a name/relative-path reference (e.g.
|
||||||
|
// "lark-contact/references/lark-contact-search-user.md") and resolves to that
|
||||||
|
// path directly. Both render as `lark-cli skills read <entry>` — the slash form
|
||||||
|
// skills read already accepts — so a per-command entry can point at that
|
||||||
|
// command's own reference file, not just re-point the domain skill.
|
||||||
|
func SkillStatPath(entry string) string {
|
||||||
|
if strings.Contains(entry, "/") {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
return entry + "/SKILL.md"
|
||||||
|
}
|
||||||
|
|
||||||
// headingToKey maps a command heading ("instances get") to its affordance key
|
// headingToKey maps a command heading ("instances get") to its affordance key
|
||||||
// ("instances.get"). The space→dot rule holds where the command form matches
|
// ("instances.get"). The space→dot rule holds where the command form matches
|
||||||
// the method id; domains whose resource names differ (e.g. plural "messages"
|
// the method id; domains whose resource names differ (e.g. plural "messages"
|
||||||
// vs id segment "message") need the registry's authoritative resource↔id table.
|
// vs id segment "message") need the registry's authoritative resource↔id table.
|
||||||
func headingToKey(h string) string {
|
func headingToKey(h string) string {
|
||||||
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
|
h = strings.TrimSpace(h)
|
||||||
|
if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(h, " ", ".")
|
||||||
}
|
}
|
||||||
|
|
||||||
type mdSection struct {
|
type mdSection struct {
|
||||||
@@ -82,6 +123,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
|||||||
if len(useWhen) > 0 {
|
if len(useWhen) > 0 {
|
||||||
a.UseWhen = useWhen
|
a.UseWhen = useWhen
|
||||||
}
|
}
|
||||||
|
var perCmdSkills []string
|
||||||
for _, s := range secs {
|
for _, s := range secs {
|
||||||
switch standardSection[s.label] {
|
switch standardSection[s.label] {
|
||||||
case "avoid_when":
|
case "avoid_when":
|
||||||
@@ -92,12 +134,14 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
|||||||
a.Tips = s.items
|
a.Tips = s.items
|
||||||
case "examples":
|
case "examples":
|
||||||
a.Examples = s.cases
|
a.Examples = s.cases
|
||||||
|
case "skills":
|
||||||
|
perCmdSkills = s.items
|
||||||
default:
|
default:
|
||||||
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
|
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if skill != "" {
|
if s := mergeSkills(skill, perCmdSkills); len(s) > 0 {
|
||||||
a.Skills = []string{skill}
|
a.Skills = s
|
||||||
}
|
}
|
||||||
out[curKey] = a
|
out[curKey] = a
|
||||||
}
|
}
|
||||||
@@ -157,7 +201,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
|||||||
inFence, fence = true, nil
|
inFence, fence = true, nil
|
||||||
} else {
|
} else {
|
||||||
inFence = false
|
inFence = false
|
||||||
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
|
sec.cases = append(sec.cases, meta.AffordanceCase{Description: linkToBacktick(pending), Command: strings.Join(fence, "\n")})
|
||||||
pending = ""
|
pending = ""
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/policystate"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -41,11 +42,15 @@ func (e *NeedAuthorizationError) Error() string {
|
|||||||
// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for
|
// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for
|
||||||
// errors.As / errors.Is traversal.
|
// errors.As / errors.Is traversal.
|
||||||
func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError {
|
func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError {
|
||||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
e := errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||||
"%s (user: %s)", needUserAuthorizationMarker, userOpenID).
|
"%s (user: %s)", needUserAuthorizationMarker, userOpenID).
|
||||||
WithUserOpenID(userOpenID).
|
WithUserOpenID(userOpenID).
|
||||||
WithHint("run: lark-cli auth login to re-authorize").
|
|
||||||
WithCause(&NeedAuthorizationError{UserOpenId: userOpenID})
|
WithCause(&NeedAuthorizationError{UserOpenId: userOpenID})
|
||||||
|
// No recovery hint when the auth domain is absent from this build.
|
||||||
|
if !policystate.DomainDeniedByPlugin("auth") {
|
||||||
|
e = e.WithHint("run: lark-cli auth login to re-authorize")
|
||||||
|
}
|
||||||
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT
|
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT
|
||||||
|
|||||||
28
internal/auth/hint_gate_test.go
Normal file
28
internal/auth/hint_gate_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/policystate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The auth-login recovery hint points into the auth domain; when an
|
||||||
|
// integrator plugin denied that whole domain the hint would be a dead
|
||||||
|
// end, so it stays off the error.
|
||||||
|
func TestNeedUserAuthorization_hintFollowsAuthDomain(t *testing.T) {
|
||||||
|
t.Cleanup(policystate.ResetForTesting)
|
||||||
|
|
||||||
|
policystate.SetPluginDeniedDomains(nil)
|
||||||
|
if e := NewNeedUserAuthorizationError("ou_x"); !strings.Contains(e.Hint, "auth login") {
|
||||||
|
t.Errorf("default build must keep the auth login hint, got %q", e.Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
policystate.SetPluginDeniedDomains(map[string]bool{"auth": true})
|
||||||
|
if e := NewNeedUserAuthorizationError("ou_x"); e.Hint != "" {
|
||||||
|
t.Errorf("auth-denied build must not steer to auth login, got %q", e.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/credential"
|
"github.com/larksuite/cli/internal/credential"
|
||||||
"github.com/larksuite/cli/internal/errclass"
|
"github.com/larksuite/cli/internal/errclass"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/policystate"
|
||||||
"github.com/larksuite/cli/internal/util"
|
"github.com/larksuite/cli/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,10 +72,14 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s
|
|||||||
// for the defensive empty-token branch) and is preserved for errors.Is /
|
// for the defensive empty-token branch) and is preserved for errors.Is /
|
||||||
// errors.Unwrap traversal without being serialized on the wire.
|
// errors.Unwrap traversal without being serialized on the wire.
|
||||||
func newTokenMissingError(as core.Identity, cause error) error {
|
func newTokenMissingError(as core.Identity, cause error) error {
|
||||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
e := errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||||
"no access token available for %s", as).
|
"no access token available for %s", as).
|
||||||
WithHint("run: lark-cli auth login to re-authorize").
|
|
||||||
WithCause(cause)
|
WithCause(cause)
|
||||||
|
// No recovery hint when the auth domain is absent from this build.
|
||||||
|
if !policystate.DomainDeniedByPlugin("auth") {
|
||||||
|
e = e.WithHint("run: lark-cli auth login to re-authorize")
|
||||||
|
}
|
||||||
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildApiReq converts a RawApiRequest into SDK types and collects
|
// buildApiReq converts a RawApiRequest into SDK types and collects
|
||||||
|
|||||||
31
internal/client/hint_gate_test.go
Normal file
31
internal/client/hint_gate_test.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/policystate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Same gate as internal/auth: the token-missing recovery hint points into
|
||||||
|
// the auth domain and stays off the error when a plugin denied it.
|
||||||
|
func TestTokenMissing_hintFollowsAuthDomain(t *testing.T) {
|
||||||
|
t.Cleanup(policystate.ResetForTesting)
|
||||||
|
|
||||||
|
policystate.SetPluginDeniedDomains(nil)
|
||||||
|
var ae *errs.AuthenticationError
|
||||||
|
if err := newTokenMissingError(core.AsUser, nil); !errors.As(err, &ae) || !strings.Contains(ae.Hint, "auth login") {
|
||||||
|
t.Errorf("default build must keep the auth login hint, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
policystate.SetPluginDeniedDomains(map[string]bool{"auth": true})
|
||||||
|
if err := newTokenMissingError(core.AsUser, nil); !errors.As(err, &ae) || ae.Hint != "" {
|
||||||
|
t.Errorf("auth-denied build must not steer to auth login, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// Package cmdmeta is the single source of truth for command metadata that the
|
// Package cmdmeta is the single source of truth for command metadata that the
|
||||||
// policy engine and the hook selector both consume. It wraps the existing
|
// policy engine, the hook selector, and help rendering consume. It wraps the
|
||||||
// cmdutil annotations (risk_level, supportedIdentities) and adds the
|
// existing cmdutil annotations (risk_level, supportedIdentities) and adds the
|
||||||
// "domain" axis that the hook selector and Rule path globs need.
|
// "domain" axis that the hook selector and Rule path globs need, plus the
|
||||||
|
// affordance ref (service, method id) that lets service-method and shortcut
|
||||||
|
// help share one usage-guidance lookup path.
|
||||||
//
|
//
|
||||||
// Three axes:
|
// Three axes:
|
||||||
//
|
//
|
||||||
@@ -51,6 +53,12 @@ const (
|
|||||||
|
|
||||||
sourceAnnotationKey = "cmdmeta.source"
|
sourceAnnotationKey = "cmdmeta.source"
|
||||||
generatedAnnotationKey = "cmdmeta.generated"
|
generatedAnnotationKey = "cmdmeta.generated"
|
||||||
|
|
||||||
|
// affordance{Service,Method}Key locate the command's usage-guidance overlay
|
||||||
|
// entry (see internal/affordance). Both service-method commands and
|
||||||
|
// +-prefixed shortcuts set these so help rendering shares one lookup path.
|
||||||
|
affordanceServiceKey = "cmdmeta.affordance.service"
|
||||||
|
affordanceMethodKey = "cmdmeta.affordance.method"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Meta groups the three command-level metadata axes consumed by the policy
|
// Meta groups the three command-level metadata axes consumed by the policy
|
||||||
@@ -125,6 +133,35 @@ func SetSource(cmd *cobra.Command, source Source, generated bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAffordanceRef records which affordance overlay entry (service, method id)
|
||||||
|
// a command maps to, so help rendering can look up its usage guidance. Stored
|
||||||
|
// on the command itself (no inheritance): each method / shortcut owns its ref.
|
||||||
|
// A no-op if either coordinate is empty.
|
||||||
|
func SetAffordanceRef(cmd *cobra.Command, service, method string) {
|
||||||
|
if service == "" || method == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cmd.Annotations == nil {
|
||||||
|
cmd.Annotations = map[string]string{}
|
||||||
|
}
|
||||||
|
cmd.Annotations[affordanceServiceKey] = service
|
||||||
|
cmd.Annotations[affordanceMethodKey] = method
|
||||||
|
}
|
||||||
|
|
||||||
|
// AffordanceRef returns the command's own affordance overlay coordinates.
|
||||||
|
// ok is false when the command carries no ref.
|
||||||
|
func AffordanceRef(cmd *cobra.Command) (service, method string, ok bool) {
|
||||||
|
if cmd.Annotations == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
service = cmd.Annotations[affordanceServiceKey]
|
||||||
|
method = cmd.Annotations[affordanceMethodKey]
|
||||||
|
if service == "" || method == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return service, method, true
|
||||||
|
}
|
||||||
|
|
||||||
// Domain returns the nearest-ancestor domain for the command. Empty string
|
// Domain returns the nearest-ancestor domain for the command. Empty string
|
||||||
// when no ancestor has the annotation -- this is the "unknown" state the
|
// when no ancestor has the annotation -- this is the "unknown" state the
|
||||||
// policy engine must treat as ALLOW.
|
// policy engine must treat as ALLOW.
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ type ActivePolicy struct {
|
|||||||
Rules []*platform.Rule
|
Rules []*platform.Rule
|
||||||
Source ResolveSource
|
Source ResolveSource
|
||||||
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
|
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
|
||||||
|
|
||||||
|
// DeniedByPath is the full post-aggregation denial map.
|
||||||
|
DeniedByPath map[string]Denial
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -81,6 +84,12 @@ func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
|
|||||||
cp.Rules[i] = &rule
|
cp.Rules[i] = &rule
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if in.DeniedByPath != nil {
|
||||||
|
cp.DeniedByPath = make(map[string]Denial, len(in.DeniedByPath))
|
||||||
|
for k, v := range in.DeniedByPath {
|
||||||
|
cp.DeniedByPath[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
return &cp
|
return &cp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func TestBuildDeniedByPath_parentAggregationAllChildrenDenied(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions,
|
denied := cmdpolicy.BuildDeniedByPath(root, decisions,
|
||||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/policy.yml"}, "agent")
|
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/policy.yml"}, "agent", "")
|
||||||
|
|
||||||
// Both leaves denied.
|
// Both leaves denied.
|
||||||
if _, ok := denied["im/+send"]; !ok {
|
if _, ok := denied["im/+send"]; !ok {
|
||||||
@@ -110,7 +110,7 @@ func TestBuildDeniedByPath_partialDenialKeepsParent(t *testing.T) {
|
|||||||
Deny: []string{"docs/+delete"},
|
Deny: []string{"docs/+delete"},
|
||||||
})
|
})
|
||||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, "secaudit-policy")
|
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, "secaudit-policy", "")
|
||||||
|
|
||||||
if _, ok := denied["docs"]; ok {
|
if _, ok := denied["docs"]; ok {
|
||||||
t.Errorf("parent 'docs' must NOT be denied when some children are allowed")
|
t.Errorf("parent 'docs' must NOT be denied when some children are allowed")
|
||||||
@@ -129,7 +129,7 @@ func TestBuildDeniedByPath_rootNeverDenied(t *testing.T) {
|
|||||||
root := buildTree()
|
root := buildTree()
|
||||||
e := cmdpolicy.New(&platform.Rule{Allow: []string{"nonexistent/**"}})
|
e := cmdpolicy.New(&platform.Rule{Allow: []string{"nonexistent/**"}})
|
||||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/p.yml"}, "")
|
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/p.yml"}, "", "")
|
||||||
|
|
||||||
// Every leaf should be denied. We do not assert on the root entry
|
// Every leaf should be denied. We do not assert on the root entry
|
||||||
// because Apply skips the root regardless; the contract is "root
|
// because Apply skips the root regardless; the contract is "root
|
||||||
@@ -156,7 +156,7 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
|
|||||||
Allow: []string{"docs"},
|
Allow: []string{"docs"},
|
||||||
})
|
})
|
||||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: ""}, "")
|
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: ""}, "", "")
|
||||||
|
|
||||||
// docs/+delete denied (path doesn't match Allow=["docs"]).
|
// docs/+delete denied (path doesn't match Allow=["docs"]).
|
||||||
if _, ok := denied["docs/+delete"]; !ok {
|
if _, ok := denied["docs/+delete"]; !ok {
|
||||||
@@ -170,13 +170,13 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
|
|||||||
|
|
||||||
// Apply returns a typed *errs.ValidationError that exposes BOTH paths
|
// Apply returns a typed *errs.ValidationError that exposes BOTH paths
|
||||||
// consumers rely on:
|
// consumers rely on:
|
||||||
// 1. cmd/root.go's envelope writer (errs.ProblemOf / failed_precondition
|
// 1. cmd/root.go's envelope writer (errs.ProblemOf; plugin-source
|
||||||
// subtype + exit code 2)
|
// denials use subtype command_unavailable + exit code 2)
|
||||||
// 2. in-process consumers extracting the platform.CommandDeniedError as
|
// 2. in-process consumers extracting the platform.CommandDeniedError as
|
||||||
// the typed error's Cause via errors.As
|
// the typed error's Cause via errors.As
|
||||||
//
|
//
|
||||||
// The policy metadata (layer / policy_source / rule_name / reason_code)
|
// Plugin-source denials keep the policy metadata OFF the wire (no hint,
|
||||||
// is folded into the Hint text rather than a separate detail map.
|
// no source / rule vocabulary); it stays reachable on the Cause only.
|
||||||
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
|
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
|
||||||
root := buildTree()
|
root := buildTree()
|
||||||
denied := map[string]cmdpolicy.Denial{
|
denied := map[string]cmdpolicy.Denial{
|
||||||
@@ -196,29 +196,31 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
|
|||||||
t.Fatalf("denied command should return error")
|
t.Fatalf("denied command should return error")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path 1: typed-envelope view. The denial is a failed_precondition
|
// Path 1: typed-envelope view. A plugin-source denial presents as
|
||||||
// ValidationError so cmd/root.go renders the structured envelope and
|
// "command unavailable": the capability is absent from this build, so
|
||||||
// the process exits 2 (ExitValidation).
|
// the envelope carries no policy metadata and no recovery hint.
|
||||||
var ve *errs.ValidationError
|
var ve *errs.ValidationError
|
||||||
if !errors.As(err, &ve) {
|
if !errors.As(err, &ve) {
|
||||||
t.Fatalf("error chain must contain *errs.ValidationError, got %T", err)
|
t.Fatalf("error chain must contain *errs.ValidationError, got %T", err)
|
||||||
}
|
}
|
||||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
if ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
|
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeCommandUnavailable)
|
||||||
}
|
}
|
||||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||||
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
|
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
|
||||||
}
|
}
|
||||||
// The policy metadata is folded into the Hint text: reason_code,
|
if ve.Message != cmdpolicy.DefaultUnavailableMessage {
|
||||||
// policy_source, and rule_name must all be discoverable there.
|
t.Errorf("message = %q, want default unavailable message", ve.Message)
|
||||||
if !strings.Contains(ve.Hint, "write_not_allowed") {
|
|
||||||
t.Errorf("hint must carry reason_code write_not_allowed, got %q", ve.Hint)
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(ve.Hint, "plugin:secaudit") {
|
// No hint, no policy vocabulary: the wire must not steer the caller
|
||||||
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
|
// toward a policy the integrator locked into the build.
|
||||||
|
if ve.Hint != "" {
|
||||||
|
t.Errorf("plugin-source denial must carry no hint, got %q", ve.Hint)
|
||||||
|
}
|
||||||
|
for _, leak := range []string{"policy", "plugin:secaudit", "secaudit-policy", "write_not_allowed"} {
|
||||||
|
if strings.Contains(ve.Message, leak) {
|
||||||
|
t.Errorf("message leaks %q: %q", leak, ve.Message)
|
||||||
}
|
}
|
||||||
if !strings.Contains(ve.Hint, "secaudit-policy") {
|
|
||||||
t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError
|
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError
|
||||||
@@ -301,7 +303,7 @@ func TestHasRunnableDescendant_ignoresAnnotatedPureGroup(t *testing.T) {
|
|||||||
|
|
||||||
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
|
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
|
||||||
decisions := e.EvaluateAll(root)
|
decisions := e.EvaluateAll(root)
|
||||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
|
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "", "")
|
||||||
|
|
||||||
if _, ok := denied["docs"]; !ok {
|
if _, ok := denied["docs"]; !ok {
|
||||||
t.Fatalf("docs should be aggregated as fully denied (pure-group children excluded from live count); map=%+v", denied)
|
t.Fatalf("docs should be aggregated as fully denied (pure-group children excluded from live count); map=%+v", denied)
|
||||||
@@ -332,7 +334,7 @@ func TestBuildDeniedByPath_aggregatesAnnotatedPureGroup(t *testing.T) {
|
|||||||
|
|
||||||
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
|
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
|
||||||
decisions := e.EvaluateAll(root)
|
decisions := e.EvaluateAll(root)
|
||||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
|
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "", "")
|
||||||
|
|
||||||
if _, ok := denied["drive"]; !ok {
|
if _, ok := denied["drive"]; !ok {
|
||||||
t.Fatalf("aggregator must install drive denial when all children denied; map=%+v", denied)
|
t.Fatalf("aggregator must install drive denial when all children denied; map=%+v", denied)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
package cmdpolicy
|
package cmdpolicy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
@@ -73,6 +75,10 @@ const (
|
|||||||
AnnotationDenialLayer = "lark:policy_denied_layer"
|
AnnotationDenialLayer = "lark:policy_denied_layer"
|
||||||
AnnotationDenialSource = "lark:policy_denied_source"
|
AnnotationDenialSource = "lark:policy_denied_source"
|
||||||
|
|
||||||
|
// AnnotationDenialMessage carries the resolved unavailable message
|
||||||
|
// for the cmd layer's help interceptor (plugin-source denials only).
|
||||||
|
AnnotationDenialMessage = "lark:policy_denied_message"
|
||||||
|
|
||||||
// AnnotationPureGroup marks a cobra.Command that is logically a
|
// AnnotationPureGroup marks a cobra.Command that is logically a
|
||||||
// parent-only group but had a RunE attached by the bootstrap-time
|
// parent-only group but had a RunE attached by the bootstrap-time
|
||||||
// unknown-subcommand guard. The engine treats annotated commands
|
// unknown-subcommand guard. The engine treats annotated commands
|
||||||
@@ -124,6 +130,37 @@ func BuildDenialError(path string, d Denial) *errs.ValidationError {
|
|||||||
WithCause(cd)
|
WithCause(cd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsPluginPolicySource reports whether a policy source names a plugin.
|
||||||
|
// Plugin sources select the "command unavailable" presentation.
|
||||||
|
func IsPluginPolicySource(source string) bool {
|
||||||
|
return strings.HasPrefix(source, "plugin:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultUnavailableMessage is the message shown when a plugin-restricted
|
||||||
|
// command is invoked and the integrator supplied no Rule.DeniedMessage.
|
||||||
|
const DefaultUnavailableMessage = "command not included in this build"
|
||||||
|
|
||||||
|
// messageOf resolves the effective unavailable message for a denial.
|
||||||
|
func messageOf(d Denial) string {
|
||||||
|
if d.DeniedMessage != "" {
|
||||||
|
return d.DeniedMessage
|
||||||
|
}
|
||||||
|
return DefaultUnavailableMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildUnavailableError is the plugin-source counterpart of
|
||||||
|
// BuildDenialError: no hint, no policy vocabulary on the wire. The
|
||||||
|
// *platform.CommandDeniedError stays reachable as the Cause for
|
||||||
|
// in-process consumers; Cause is never serialized.
|
||||||
|
func BuildUnavailableError(path string, d Denial) *errs.ValidationError {
|
||||||
|
msg := d.DeniedMessage
|
||||||
|
if msg == "" {
|
||||||
|
msg = DefaultUnavailableMessage
|
||||||
|
}
|
||||||
|
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg).
|
||||||
|
WithCause(CommandDeniedFromDenial(path, d))
|
||||||
|
}
|
||||||
|
|
||||||
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go
|
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go
|
||||||
// which does RemoveCommand+AddCommand (changing the pointer), we modify
|
// which does RemoveCommand+AddCommand (changing the pointer), we modify
|
||||||
// the existing node so any external reference (snapshots, alias targets)
|
// the existing node so any external reference (snapshots, alias targets)
|
||||||
@@ -194,12 +231,20 @@ func installDenyStub(cmd *cobra.Command, path string, d Denial) bool {
|
|||||||
cmd.Annotations[AnnotationDenialSource] = d.PolicySource
|
cmd.Annotations[AnnotationDenialSource] = d.PolicySource
|
||||||
|
|
||||||
denial := d // capture by value for the closure
|
denial := d // capture by value for the closure
|
||||||
|
if IsPluginPolicySource(d.PolicySource) {
|
||||||
|
// The message annotation feeds the cmd layer's help interceptor.
|
||||||
|
cmd.Annotations[AnnotationDenialMessage] = messageOf(d)
|
||||||
|
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||||
|
return BuildUnavailableError(path, denial)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||||
// The typed message carries the user-facing semantic ("a command
|
// The typed message carries the user-facing semantic ("a command
|
||||||
// was denied"); the hint carries the layer / source / rule
|
// was denied"); the hint carries the layer / source / rule
|
||||||
// distinction ("policy" vs "strict_mode") for debugging.
|
// distinction ("policy" vs "strict_mode") for debugging.
|
||||||
return BuildDenialError(path, denial)
|
return BuildDenialError(path, denial)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Clear any pre-existing Run hook: cobra prefers RunE when both are
|
// Clear any pre-existing Run hook: cobra prefers RunE when both are
|
||||||
// set, but leaving a stale Run around is a foot-gun for future
|
// set, but leaving a stale Run around is a foot-gun for future
|
||||||
// maintainers.
|
// maintainers.
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ type Denial struct {
|
|||||||
RuleName string // matched Rule.Name (if any)
|
RuleName string // matched Rule.Name (if any)
|
||||||
ReasonCode string // closed enum, see docs/extension/reason-codes.md
|
ReasonCode string // closed enum, see docs/extension/reason-codes.md
|
||||||
Reason string // human-readable
|
Reason string // human-readable
|
||||||
|
|
||||||
|
// DeniedMessage is Rule.DeniedMessage for the plugin-source
|
||||||
|
// unavailable presentation; empty means the default message.
|
||||||
|
DeniedMessage string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChildDenial is what AggregateChildren consumes — it pairs a Denial
|
// ChildDenial is what AggregateChildren consumes — it pairs a Denial
|
||||||
|
|||||||
@@ -27,3 +27,15 @@ var diagnosticPaths = map[string]bool{
|
|||||||
func IsDiagnosticPath(path string) bool {
|
func IsDiagnosticPath(path string) bool {
|
||||||
return diagnosticPaths[path]
|
return diagnosticPaths[path]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiagnosticPaths returns the exempt self-inspection command paths, for
|
||||||
|
// the presentation layer: an integrator's HideDiagnostics retires exactly
|
||||||
|
// this set, and the help-concealment pass hides it from listings when the
|
||||||
|
// surrounding domain is plugin-denied.
|
||||||
|
func DiagnosticPaths() []string {
|
||||||
|
out := make([]string, 0, len(diagnosticPaths))
|
||||||
|
for p := range diagnosticPaths {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -269,8 +269,9 @@ func mergeDenials(rules []*platform.Rule, denials []Decision) Decision {
|
|||||||
// so `--help` and similar remain available.
|
// so `--help` and similar remain available.
|
||||||
//
|
//
|
||||||
// source / ruleName populate PolicySource and RuleName on the produced
|
// source / ruleName populate PolicySource and RuleName on the produced
|
||||||
// Denial values, so envelope output can attribute denials.
|
// Denial values, so envelope output can attribute denials. deniedMessage
|
||||||
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string) map[string]Denial {
|
// is build-level and applies uniformly, aggregates included.
|
||||||
|
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string, deniedMessage string) map[string]Denial {
|
||||||
out := map[string]Denial{}
|
out := map[string]Denial{}
|
||||||
|
|
||||||
sourceLabel := policySourceLabel(source)
|
sourceLabel := policySourceLabel(source)
|
||||||
@@ -287,6 +288,13 @@ func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, sourc
|
|||||||
}
|
}
|
||||||
|
|
||||||
aggregateParents(root, out)
|
aggregateParents(root, out)
|
||||||
|
|
||||||
|
if deniedMessage != "" {
|
||||||
|
for path, d := range out {
|
||||||
|
d.DeniedMessage = deniedMessage
|
||||||
|
out[path] = d
|
||||||
|
}
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestEnvelope_yamlPolicySourceDoesNotLeakHomePath(t *testing.T) {
|
|||||||
cmdpolicy.ResolveSource{
|
cmdpolicy.ResolveSource{
|
||||||
Kind: cmdpolicy.SourceYAML,
|
Kind: cmdpolicy.SourceYAML,
|
||||||
Name: "/Users/alice/.lark-cli/policy.yml", // simulate an absolute path
|
Name: "/Users/alice/.lark-cli/policy.yml", // simulate an absolute path
|
||||||
}, "my-readonly-rule")
|
}, "my-readonly-rule", "")
|
||||||
|
|
||||||
cmdpolicy.Apply(root, denied)
|
cmdpolicy.Apply(root, denied)
|
||||||
err := leaf.RunE(leaf, nil)
|
err := leaf.RunE(leaf, nil)
|
||||||
@@ -77,7 +77,7 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
|
|||||||
})
|
})
|
||||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"},
|
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"},
|
||||||
"secaudit-policy")
|
"secaudit-policy", "")
|
||||||
cmdpolicy.Apply(root, denied)
|
cmdpolicy.Apply(root, denied)
|
||||||
|
|
||||||
err := leaf.RunE(leaf, nil)
|
err := leaf.RunE(leaf, nil)
|
||||||
@@ -85,10 +85,17 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
|
|||||||
if !errors.As(err, &ve) {
|
if !errors.As(err, &ve) {
|
||||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
}
|
}
|
||||||
// The plugin name IS surfaced (in-binary, part of the contract): it
|
// A plugin-source denial presents as absent: no hint, no plugin name
|
||||||
// must appear in the Hint so an integrator debugging a denial knows
|
// on the wire. Plugin attribution moves to the in-process Cause
|
||||||
// which plugin fired.
|
// (*platform.CommandDeniedError) for integrators debugging a denial.
|
||||||
if !strings.Contains(ve.Hint, "plugin:secaudit") {
|
if ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||||
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
|
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeCommandUnavailable)
|
||||||
|
}
|
||||||
|
if ve.Hint != "" || strings.Contains(ve.Message, "plugin:secaudit") {
|
||||||
|
t.Errorf("wire must not expose the plugin source; hint=%q message=%q", ve.Hint, ve.Message)
|
||||||
|
}
|
||||||
|
var cd *platform.CommandDeniedError
|
||||||
|
if !errors.As(err, &cd) || cd.PolicySource != "plugin:secaudit" {
|
||||||
|
t.Errorf("in-process Cause must carry plugin:secaudit, got %+v", cd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
|
|||||||
WithParam("--file").
|
WithParam("--file").
|
||||||
WithCause(err)
|
WithCause(err)
|
||||||
}
|
}
|
||||||
fd.AddFile(fieldName, bytes.NewReader(data))
|
fd.AddFileWithName(fieldName, filepath.Base(filePath), bytes.NewReader(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add top-level JSON keys as text form fields.
|
// Add top-level JSON keys as text form fields.
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
@@ -40,8 +38,6 @@ const (
|
|||||||
BuildKindUnknown = "unknown"
|
BuildKindUnknown = "unknown"
|
||||||
|
|
||||||
officialModulePath = "github.com/larksuite/cli"
|
officialModulePath = "github.com/larksuite/cli"
|
||||||
|
|
||||||
agentTraceMaxLen = 1024
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||||
@@ -49,25 +45,6 @@ func UserAgentValue() string {
|
|||||||
return SourceValue + "/" + build.Version
|
return SourceValue + "/" + build.Version
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentTraceValue returns a header-safe value from the
|
|
||||||
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
|
|
||||||
// surrounding whitespace, rejects values containing any Unicode
|
|
||||||
// control character or exceeding agentTraceMaxLen, and returns ""
|
|
||||||
// for any invalid or empty value. Callers can use the result
|
|
||||||
// directly in HTTP headers without further sanitisation.
|
|
||||||
func AgentTraceValue() string {
|
|
||||||
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
|
|
||||||
if v == "" || len(v) > agentTraceMaxLen {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, r := range v {
|
|
||||||
if unicode.IsControl(r) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// BaseSecurityHeaders returns headers that every request must carry.
|
// BaseSecurityHeaders returns headers that every request must carry.
|
||||||
func BaseSecurityHeaders() http.Header {
|
func BaseSecurityHeaders() http.Header {
|
||||||
h := make(http.Header)
|
h := make(http.Header)
|
||||||
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
|
|||||||
h.Set(HeaderVersion, build.Version)
|
h.Set(HeaderVersion, build.Version)
|
||||||
h.Set(HeaderBuild, DetectBuildKind())
|
h.Set(HeaderBuild, DetectBuildKind())
|
||||||
h.Set(HeaderUserAgent, UserAgentValue())
|
h.Set(HeaderUserAgent, UserAgentValue())
|
||||||
if v := AgentTraceValue(); v != "" {
|
if v := envvars.AgentTrace(); v != "" {
|
||||||
h.Set(HeaderAgentTrace, v)
|
h.Set(HeaderAgentTrace, v)
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AgentTraceValue / HeaderAgentTrace
|
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
|
|
||||||
if got := AgentTraceValue(); got != "trace-abc-123" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
|
|
||||||
if got := AgentTraceValue(); got != "trace-trim" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, " ")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsLF(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsTab(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
|
|
||||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
|
||||||
t.Setenv(envvars.CliAgentTrace, longVal)
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
|
|
||||||
val := strings.Repeat("a", agentTraceMaxLen)
|
|
||||||
t.Setenv(envvars.CliAgentTrace, val)
|
|
||||||
if got := AgentTraceValue(); got != val {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
t.Setenv(envvars.CliAgentTrace, "")
|
||||||
h := BaseSecurityHeaders()
|
h := BaseSecurityHeaders()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
// Content safety scanning mode
|
// Content safety scanning mode
|
||||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||||
|
|
||||||
|
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||||
|
|
||||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||||
|
|||||||
36
internal/envvars/read.go
Normal file
36
internal/envvars/read.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package envvars
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
agentNameMaxLen = 128
|
||||||
|
agentTraceMaxLen = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
func AgentName() string {
|
||||||
|
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AgentTrace() string {
|
||||||
|
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSingleLine(raw string, maxLen int) string {
|
||||||
|
v := strings.TrimSpace(raw)
|
||||||
|
if v == "" || len(v) > maxLen {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range v {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
131
internal/envvars/read_test.go
Normal file
131
internal/envvars/read_test.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package envvars
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty when env unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "claude-code")
|
||||||
|
if got := AgentName(); got != "claude-code" {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, " cursor ")
|
||||||
|
if got := AgentName(); got != "cursor" {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsControlChar(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "agent\x01injected")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty for control char value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsOverlongValue(t *testing.T) {
|
||||||
|
longVal := strings.Repeat("a", agentNameMaxLen+1)
|
||||||
|
t.Setenv(CliAgentName, longVal)
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "trace-abc-123")
|
||||||
|
if got := AgentTrace(); got != "trace-abc-123" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, " trace-trim ")
|
||||||
|
if got := AgentTrace(); got != "trace-trim" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, " ")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsCRLF(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsLF(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsTab(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\tinjected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsControlChar(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\x01injected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsDEL(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\x7finjected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
|
||||||
|
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||||
|
t.Setenv(CliAgentTrace, longVal)
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
|
||||||
|
val := strings.Repeat("a", agentTraceMaxLen)
|
||||||
|
t.Setenv(CliAgentTrace, val)
|
||||||
|
if got := AgentTrace(); got != val {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,6 +113,7 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
|
|||||||
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
|
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
|
||||||
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
|
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
|
||||||
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
|
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
|
||||||
|
{"1062507 drive_parent_sibling_limit", 1062507, errs.CategoryAPI, errs.SubtypeQuotaExceeded, 1, "APIError"},
|
||||||
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
||||||
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
||||||
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ var driveCodeMeta = map[int]CodeMeta{
|
|||||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||||
|
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||||
|
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
|
||||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||||
@@ -24,6 +26,7 @@ var driveCodeMeta = map[int]CodeMeta{
|
|||||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
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
|
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||||
|
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||||
|
|||||||
@@ -114,8 +114,35 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
|||||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||||
|
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||||
|
{233523001, 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_WikiCodes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
code int
|
||||||
|
wantCat errs.Category
|
||||||
|
wantSubtype errs.Subtype
|
||||||
|
wantRetry bool
|
||||||
|
}{
|
||||||
|
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
|
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||||
|
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 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) {
|
||||||
|
|||||||
17
internal/errclass/codemeta_wiki.go
Normal file
17
internal/errclass/codemeta_wiki.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package errclass
|
||||||
|
|
||||||
|
import "github.com/larksuite/cli/errs"
|
||||||
|
|
||||||
|
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
|
||||||
|
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
|
||||||
|
// command-specific recovery guidance at the shortcut layer.
|
||||||
|
var wikiCodeMeta = map[int]CodeMeta{
|
||||||
|
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
|
||||||
|
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
|
||||||
|
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }
|
||||||
@@ -8,8 +8,11 @@ import "encoding/json"
|
|||||||
// Affordance is the typed usage guidance overlaid on a method. It is the single
|
// Affordance is the typed usage guidance overlaid on a method. It is the single
|
||||||
// model the envelope renderer and the command help both parse, so the
|
// model the envelope renderer and the command help both parse, so the
|
||||||
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
|
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
|
||||||
// Skills entries are skill names (or name/path) rendered as runnable
|
// Skills entries are either a bare skill name (e.g. "lark-doc") or a
|
||||||
// `lark-cli skills read <entry>` pointers.
|
// name/relative-path reference (e.g. "lark-contact/references/x.md"); both
|
||||||
|
// render as runnable `lark-cli skills read <entry>` pointers. Help validates
|
||||||
|
// each against the embedded skill tree (a name → its SKILL.md, a reference →
|
||||||
|
// that path) and drops any that do not resolve.
|
||||||
type Affordance struct {
|
type Affordance struct {
|
||||||
UseWhen []string `json:"use_when,omitempty"`
|
UseWhen []string `json:"use_when,omitempty"`
|
||||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||||
|
|||||||
@@ -53,4 +53,12 @@ const (
|
|||||||
ReasonInstallPanic = "install_panic"
|
ReasonInstallPanic = "install_panic"
|
||||||
ReasonDuplicatePluginName = "duplicate_plugin_name"
|
ReasonDuplicatePluginName = "duplicate_plugin_name"
|
||||||
ReasonMultipleRestricts = "multiple_restrict_plugins"
|
ReasonMultipleRestricts = "multiple_restrict_plugins"
|
||||||
|
// ReasonInvalidSkillsOverlay flags a plugin's SkillsOverlay that cannot
|
||||||
|
// compose -- EmbeddedSkills() called twice, a Remove naming a skill absent
|
||||||
|
// from the base, or an Overlay entry missing SKILL.md.
|
||||||
|
ReasonInvalidSkillsOverlay = "invalid_skills_overlay"
|
||||||
|
// ReasonMultipleSkillsOverlays flags two or more plugins each contributing
|
||||||
|
// a SkillsOverlay; only one may own skill content (mirrors
|
||||||
|
// ReasonMultipleRestricts).
|
||||||
|
ReasonMultipleSkillsOverlays = "multiple_skills_overlay_plugins"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/larksuite/cli/extension/platform"
|
"github.com/larksuite/cli/extension/platform"
|
||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
"github.com/larksuite/cli/internal/hook"
|
"github.com/larksuite/cli/internal/hook"
|
||||||
|
"github.com/larksuite/cli/internal/skillpolicy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PluginInfo is the metadata of a successfully-installed plugin,
|
// PluginInfo is the metadata of a successfully-installed plugin,
|
||||||
@@ -31,6 +32,7 @@ type PluginInfo struct {
|
|||||||
type InstallResult struct {
|
type InstallResult struct {
|
||||||
Registry *hook.Registry
|
Registry *hook.Registry
|
||||||
PluginRules []cmdpolicy.PluginRule
|
PluginRules []cmdpolicy.PluginRule
|
||||||
|
PluginSkills []skillpolicy.PluginSkill
|
||||||
Plugins []PluginInfo
|
Plugins []PluginInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +144,16 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Builder rejects this at Build time; hand-written plugins are
|
||||||
|
// caught here (authoring error, aborts unconditionally).
|
||||||
|
if caps.HideDiagnostics && !caps.Restricts {
|
||||||
|
return &PluginInstallError{
|
||||||
|
PluginName: name,
|
||||||
|
ReasonCode: ReasonInvalidCapability,
|
||||||
|
Reason: "HideDiagnostics=true requires Restricts=true",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Version compatibility check. Two distinct failure modes:
|
// Version compatibility check. Two distinct failure modes:
|
||||||
//
|
//
|
||||||
// 1. Parse error (constraint is malformed, e.g. ">=abc")
|
// 1. Parse error (constraint is malformed, e.g. ">=abc")
|
||||||
@@ -207,6 +219,12 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error {
|
|||||||
Rule: rule,
|
Rule: rule,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if staging.skillsOverlay != nil {
|
||||||
|
result.PluginSkills = append(result.PluginSkills, skillpolicy.PluginSkill{
|
||||||
|
PluginName: name,
|
||||||
|
SkillsOverlay: staging.skillsOverlay,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Record the plugin in the inventory. Version is fetched here under
|
// Record the plugin in the inventory. Version is fetched here under
|
||||||
// a recover-wrapped helper so a plugin's Version() panic does not
|
// a recover-wrapped helper so a plugin's Version() panic does not
|
||||||
|
|||||||
@@ -431,3 +431,79 @@ func TestInstallAll_multipleRestrictPerPlugin(t *testing.T) {
|
|||||||
result.PluginRules[0].Rule.Name, result.PluginRules[1].Rule.Name)
|
result.PluginRules[0].Rule.Name, result.PluginRules[1].Rule.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skillPlugin contributes a SkillsOverlay via r.EmbeddedSkills; the install pipeline
|
||||||
|
// must capture it into PluginSkills for the skill resolver.
|
||||||
|
type skillPlugin struct{}
|
||||||
|
|
||||||
|
func (skillPlugin) Name() string { return "skiller" }
|
||||||
|
func (skillPlugin) Version() string { return "1.0.0" }
|
||||||
|
func (skillPlugin) Capabilities() platform.Capabilities {
|
||||||
|
return platform.Capabilities{FailurePolicy: platform.FailOpen}
|
||||||
|
}
|
||||||
|
func (skillPlugin) Install(r platform.Registrar) error {
|
||||||
|
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-shared"}})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallAll_skillsCommitted(t *testing.T) {
|
||||||
|
result, err := internalplatform.InstallAll([]platform.Plugin{skillPlugin{}}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InstallAll: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.PluginSkills) != 1 {
|
||||||
|
t.Fatalf("PluginSkills = %d, want 1", len(result.PluginSkills))
|
||||||
|
}
|
||||||
|
ps := result.PluginSkills[0]
|
||||||
|
if ps.PluginName != "skiller" {
|
||||||
|
t.Errorf("PluginName = %q, want skiller", ps.PluginName)
|
||||||
|
}
|
||||||
|
if ps.SkillsOverlay == nil || len(ps.SkillsOverlay.Remove) != 1 || ps.SkillsOverlay.Remove[0] != "lark-shared" {
|
||||||
|
t.Errorf("Spec = %+v, want Remove=[lark-shared]", ps.SkillsOverlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// doubleSkillPlugin calls r.EmbeddedSkills twice; staging must reject it. Declared
|
||||||
|
// FailClosed so the staging error aborts InstallAll deterministically.
|
||||||
|
type doubleSkillPlugin struct{}
|
||||||
|
|
||||||
|
func (doubleSkillPlugin) Name() string { return "double-skiller" }
|
||||||
|
func (doubleSkillPlugin) Version() string { return "1.0.0" }
|
||||||
|
func (doubleSkillPlugin) Capabilities() platform.Capabilities {
|
||||||
|
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||||
|
}
|
||||||
|
func (doubleSkillPlugin) Install(r platform.Registrar) error {
|
||||||
|
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
|
||||||
|
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallAll_skillsCalledTwice_aborts(t *testing.T) {
|
||||||
|
_, err := internalplatform.InstallAll([]platform.Plugin{doubleSkillPlugin{}}, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("calling r.EmbeddedSkills twice must abort a FailClosed plugin")
|
||||||
|
}
|
||||||
|
var pi *internalplatform.PluginInstallError
|
||||||
|
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidSkillsOverlay {
|
||||||
|
t.Errorf("err = %v, want PluginInstallError reason_code %s", err, internalplatform.ReasonInvalidSkillsOverlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hideDiagnosticsOnlyPlugin declares HideDiagnostics without Restricts —
|
||||||
|
// an authoring error the host rejects unconditionally.
|
||||||
|
type hideDiagnosticsOnlyPlugin struct{}
|
||||||
|
|
||||||
|
func (hideDiagnosticsOnlyPlugin) Name() string { return "hider" }
|
||||||
|
func (hideDiagnosticsOnlyPlugin) Version() string { return "1.0.0" }
|
||||||
|
func (hideDiagnosticsOnlyPlugin) Capabilities() platform.Capabilities {
|
||||||
|
return platform.Capabilities{HideDiagnostics: true, FailurePolicy: platform.FailOpen}
|
||||||
|
}
|
||||||
|
func (hideDiagnosticsOnlyPlugin) Install(platform.Registrar) error { return nil }
|
||||||
|
|
||||||
|
func TestInstallAll_hideDiagnosticsRequiresRestricts(t *testing.T) {
|
||||||
|
_, err := internalplatform.InstallAll([]platform.Plugin{hideDiagnosticsOnlyPlugin{}}, nil)
|
||||||
|
var pi *internalplatform.PluginInstallError
|
||||||
|
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidCapability {
|
||||||
|
t.Fatalf("HideDiagnostics without Restricts must abort with invalid_capability, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ type PluginEntry struct {
|
|||||||
// plugin did not call r.Restrict.
|
// plugin did not call r.Restrict.
|
||||||
Rules []*RuleView
|
Rules []*RuleView
|
||||||
|
|
||||||
|
// EmbeddedSkills summarises the plugin's EmbeddedSkills contribution;
|
||||||
|
// nil when the plugin did not customize embedded skills.
|
||||||
|
EmbeddedSkills *SkillsOverlayView `json:"embedded_skills,omitempty"`
|
||||||
|
|
||||||
Observers []HookEntry
|
Observers []HookEntry
|
||||||
Wrappers []HookEntry
|
Wrappers []HookEntry
|
||||||
Lifecycles []HookEntry
|
Lifecycles []HookEntry
|
||||||
@@ -41,6 +45,7 @@ type CapabilitiesView struct {
|
|||||||
Restricts bool `json:"restricts"`
|
Restricts bool `json:"restricts"`
|
||||||
FailurePolicy string `json:"failure_policy"`
|
FailurePolicy string `json:"failure_policy"`
|
||||||
RequiredCLIVersion string `json:"required_cli_version,omitempty"`
|
RequiredCLIVersion string `json:"required_cli_version,omitempty"`
|
||||||
|
HideDiagnostics bool `json:"hide_diagnostics,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCapabilitiesView converts a platform.Capabilities value into the
|
// NewCapabilitiesView converts a platform.Capabilities value into the
|
||||||
@@ -50,6 +55,7 @@ func NewCapabilitiesView(c platform.Capabilities) CapabilitiesView {
|
|||||||
Restricts: c.Restricts,
|
Restricts: c.Restricts,
|
||||||
FailurePolicy: failurePolicyLabel(c.FailurePolicy),
|
FailurePolicy: failurePolicyLabel(c.FailurePolicy),
|
||||||
RequiredCLIVersion: c.RequiredCLIVersion,
|
RequiredCLIVersion: c.RequiredCLIVersion,
|
||||||
|
HideDiagnostics: c.HideDiagnostics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +80,22 @@ type RuleView struct {
|
|||||||
AllowUnannotated bool `json:"allow_unannotated"`
|
AllowUnannotated bool `json:"allow_unannotated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SkillsOverlayView is the displayable summary of an EmbeddedSkills
|
||||||
|
// contribution. Overlay / Base report presence only (an fs.FS has no
|
||||||
|
// display form).
|
||||||
|
type SkillsOverlayView struct {
|
||||||
|
Allow []string `json:"allow,omitempty"`
|
||||||
|
Remove []string `json:"remove,omitempty"`
|
||||||
|
Overlay bool `json:"overlay"`
|
||||||
|
Base bool `json:"base"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillsInventorySource pairs a plugin name with its overlay summary.
|
||||||
|
type SkillsInventorySource struct {
|
||||||
|
PluginName string
|
||||||
|
View SkillsOverlayView
|
||||||
|
}
|
||||||
|
|
||||||
// Inventory is the full snapshot.
|
// Inventory is the full snapshot.
|
||||||
type Inventory struct {
|
type Inventory struct {
|
||||||
Plugins []PluginEntry
|
Plugins []PluginEntry
|
||||||
@@ -108,7 +130,7 @@ type RuleInventorySource struct {
|
|||||||
// Hooks are attributed to plugins by the namespaced name convention:
|
// Hooks are attributed to plugins by the namespaced name convention:
|
||||||
// each entry's Name starts with "<plugin>.", and we group by the
|
// each entry's Name starts with "<plugin>.", and we group by the
|
||||||
// leading segment up to the first dot.
|
// leading segment up to the first dot.
|
||||||
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource) *Inventory {
|
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource, skills []SkillsInventorySource) *Inventory {
|
||||||
byPlugin := make(map[string]*PluginEntry, len(plugins))
|
byPlugin := make(map[string]*PluginEntry, len(plugins))
|
||||||
out := &Inventory{Plugins: make([]PluginEntry, 0, len(plugins))}
|
out := &Inventory{Plugins: make([]PluginEntry, 0, len(plugins))}
|
||||||
for _, p := range plugins {
|
for _, p := range plugins {
|
||||||
@@ -162,6 +184,15 @@ func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, ru
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, sk := range skills {
|
||||||
|
if entry := byPlugin[sk.PluginName]; entry != nil {
|
||||||
|
v := sk.View
|
||||||
|
v.Allow = append([]string(nil), sk.View.Allow...)
|
||||||
|
v.Remove = append([]string(nil), sk.View.Remove...)
|
||||||
|
entry.EmbeddedSkills = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +294,12 @@ func cloneInventory(in *Inventory) *Inventory {
|
|||||||
entry.Rules[j] = &rv
|
entry.Rules[j] = &rv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if p.EmbeddedSkills != nil {
|
||||||
|
sv := *p.EmbeddedSkills
|
||||||
|
sv.Allow = append([]string(nil), p.EmbeddedSkills.Allow...)
|
||||||
|
sv.Remove = append([]string(nil), p.EmbeddedSkills.Remove...)
|
||||||
|
entry.EmbeddedSkills = &sv
|
||||||
|
}
|
||||||
entry.Observers = append([]HookEntry(nil), p.Observers...)
|
entry.Observers = append([]HookEntry(nil), p.Observers...)
|
||||||
entry.Wrappers = append([]HookEntry(nil), p.Wrappers...)
|
entry.Wrappers = append([]HookEntry(nil), p.Wrappers...)
|
||||||
entry.Lifecycles = append([]HookEntry(nil), p.Lifecycles...)
|
entry.Lifecycles = append([]HookEntry(nil), p.Lifecycles...)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestBuildInventory_groupsByPluginName(t *testing.T) {
|
|||||||
{PluginName: "a", RuleName: "a-rule", Allow: []string{"docs/**"}, MaxRisk: "read"},
|
{PluginName: "a", RuleName: "a-rule", Allow: []string{"docs/**"}, MaxRisk: "read"},
|
||||||
}
|
}
|
||||||
|
|
||||||
inv := internalplatform.BuildInventory(plugins, r, rules)
|
inv := internalplatform.BuildInventory(plugins, r, rules, nil)
|
||||||
|
|
||||||
if got := len(inv.Plugins); got != 2 {
|
if got := len(inv.Plugins); got != 2 {
|
||||||
t.Fatalf("Plugins len = %d, want 2", got)
|
t.Fatalf("Plugins len = %d, want 2", got)
|
||||||
@@ -89,7 +89,7 @@ func TestBuildInventory_multipleRulesPerPlugin(t *testing.T) {
|
|||||||
{PluginName: "a", RuleName: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"},
|
{PluginName: "a", RuleName: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"},
|
||||||
}
|
}
|
||||||
|
|
||||||
inv := internalplatform.BuildInventory(plugins, nil, rules)
|
inv := internalplatform.BuildInventory(plugins, nil, rules, nil)
|
||||||
a := findPlugin(inv, "a")
|
a := findPlugin(inv, "a")
|
||||||
if a == nil {
|
if a == nil {
|
||||||
t.Fatalf("missing entry a")
|
t.Fatalf("missing entry a")
|
||||||
@@ -103,12 +103,45 @@ func TestBuildInventory_multipleRulesPerPlugin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildInventory_empty(t *testing.T) {
|
func TestBuildInventory_empty(t *testing.T) {
|
||||||
inv := internalplatform.BuildInventory(nil, nil, nil)
|
inv := internalplatform.BuildInventory(nil, nil, nil, nil)
|
||||||
if got := len(inv.Plugins); got != 0 {
|
if got := len(inv.Plugins); got != 0 {
|
||||||
t.Errorf("Plugins len = %d, want 0", got)
|
t.Errorf("Plugins len = %d, want 0", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A non-nil SkillsInventorySource must surface on the owning plugin's entry as
|
||||||
|
// an EmbeddedSkills summary, and BuildInventory must clone the Allow/Remove
|
||||||
|
// slices so a later mutation of the source cannot corrupt the recorded view.
|
||||||
|
func TestBuildInventory_populatesAndClonesEmbeddedSkills(t *testing.T) {
|
||||||
|
plugins := []internalplatform.PluginInventorySource{{Name: "acme", Version: "1.0"}}
|
||||||
|
allow := []string{"lark-im"}
|
||||||
|
remove := []string{"lark-shared"}
|
||||||
|
skills := []internalplatform.SkillsInventorySource{
|
||||||
|
{PluginName: "acme", View: internalplatform.SkillsOverlayView{
|
||||||
|
Allow: allow, Remove: remove, Overlay: true, Base: false,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
inv := internalplatform.BuildInventory(plugins, nil, nil, skills)
|
||||||
|
entry := findPlugin(inv, "acme")
|
||||||
|
if entry == nil || entry.EmbeddedSkills == nil {
|
||||||
|
t.Fatalf("acme entry missing EmbeddedSkills: %+v", entry)
|
||||||
|
}
|
||||||
|
es := entry.EmbeddedSkills
|
||||||
|
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
|
||||||
|
len(es.Remove) != 1 || es.Remove[0] != "lark-shared" ||
|
||||||
|
!es.Overlay || es.Base {
|
||||||
|
t.Errorf("EmbeddedSkills summary mismatch: %+v", es)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutating the source slices must not leak into the recorded view.
|
||||||
|
allow[0] = "MUTATED"
|
||||||
|
remove[0] = "MUTATED"
|
||||||
|
if es.Allow[0] != "lark-im" || es.Remove[0] != "lark-shared" {
|
||||||
|
t.Errorf("EmbeddedSkills slices not cloned; source mutation leaked: %+v", es)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func findPlugin(inv *internalplatform.Inventory, name string) *internalplatform.PluginEntry {
|
func findPlugin(inv *internalplatform.Inventory, name string) *internalplatform.PluginEntry {
|
||||||
for i := range inv.Plugins {
|
for i := range inv.Plugins {
|
||||||
if inv.Plugins[i].Name == name {
|
if inv.Plugins[i].Name == name {
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ type stagingRegistrar struct {
|
|||||||
// can detect the call.
|
// can detect the call.
|
||||||
actuallyRestricted bool
|
actuallyRestricted bool
|
||||||
|
|
||||||
|
// skillsOverlay holds the staged Skills contribution, captured for the
|
||||||
|
// host to feed into the skill resolver later. nil means the plugin
|
||||||
|
// did not call r.EmbeddedSkills. overlaySet records that the call happened so a
|
||||||
|
// second call in the same plugin can be rejected.
|
||||||
|
skillsOverlay *platform.SkillsOverlay
|
||||||
|
overlaySet bool
|
||||||
|
|
||||||
// seenHookNames detects duplicate hookName within this plugin's
|
// seenHookNames detects duplicate hookName within this plugin's
|
||||||
// Install call.
|
// Install call.
|
||||||
seenHookNames map[string]bool
|
seenHookNames map[string]bool
|
||||||
@@ -144,6 +151,25 @@ func (r *stagingRegistrar) Restrict(rule *platform.Rule) {
|
|||||||
r.rules = append(r.rules, &cp)
|
r.rules = append(r.rules, &cp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *stagingRegistrar) EmbeddedSkills(spec *platform.SkillsOverlay) {
|
||||||
|
if r.overlaySet {
|
||||||
|
r.bufferErr(ReasonInvalidSkillsOverlay, "EmbeddedSkills() called more than once in the same plugin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.overlaySet = true
|
||||||
|
if spec == nil {
|
||||||
|
r.bufferErr(ReasonInvalidSkillsOverlay, "EmbeddedSkills(nil)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Defensive clone: freeze Remove so a plugin cannot mutate it after
|
||||||
|
// Install returns. Overlay/Base are read-only fs.FS views retained by
|
||||||
|
// reference.
|
||||||
|
cp := *spec
|
||||||
|
cp.Allow = append([]string(nil), spec.Allow...)
|
||||||
|
cp.Remove = append([]string(nil), spec.Remove...)
|
||||||
|
r.skillsOverlay = &cp
|
||||||
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
func (r *stagingRegistrar) namespaced(name string) string {
|
func (r *stagingRegistrar) namespaced(name string) string {
|
||||||
|
|||||||
47
internal/policystate/policystate.go
Normal file
47
internal/policystate/policystate.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package policystate answers "did an integrator plugin deny this whole
|
||||||
|
// command domain?" for render-time hint emitters. It is dependency-free
|
||||||
|
// because internal/auth and internal/client sit below internal/cmdpolicy
|
||||||
|
// in the import graph and cannot ask it directly. Written once by the
|
||||||
|
// bootstrap after policy pruning; read-only thereafter.
|
||||||
|
package policystate
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.RWMutex
|
||||||
|
pluginDeniedDomains map[string]bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetPluginDeniedDomains records the plugin-denied top-level domains.
|
||||||
|
// nil clears.
|
||||||
|
func SetPluginDeniedDomains(domains map[string]bool) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if domains == nil {
|
||||||
|
pluginDeniedDomains = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cp := make(map[string]bool, len(domains))
|
||||||
|
for d, v := range domains {
|
||||||
|
cp[d] = v
|
||||||
|
}
|
||||||
|
pluginDeniedDomains = cp
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainDeniedByPlugin reports whether the whole top-level domain was
|
||||||
|
// denied by an integrator plugin. yaml denials never register here.
|
||||||
|
func DomainDeniedByPlugin(domain string) bool {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
return pluginDeniedDomains[domain]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetForTesting clears the recorded state.
|
||||||
|
func ResetForTesting() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
pluginDeniedDomains = nil
|
||||||
|
}
|
||||||
@@ -6,8 +6,7 @@ package registry
|
|||||||
import "github.com/larksuite/cli/internal/apicatalog"
|
import "github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
|
||||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||||
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
// metadata — deterministic across machines, for golden tests and schema lint.
|
||||||
// and schema lint.
|
|
||||||
func EmbeddedCatalog() apicatalog.Catalog {
|
func EmbeddedCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||||
}
|
}
|
||||||
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
|
|||||||
func RuntimeCatalog() apicatalog.Catalog {
|
func RuntimeCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
|
||||||
|
// otherwise the merged runtime catalog. Binaries built from the bare Go module
|
||||||
|
// embed only the empty meta_data_default.json stub, so the embedded view has
|
||||||
|
// nothing to resolve; the merged view is the only data such binaries have.
|
||||||
|
func SchemaCatalog() apicatalog.Catalog {
|
||||||
|
if len(EmbeddedServicesTyped()) > 0 {
|
||||||
|
return EmbeddedCatalog()
|
||||||
|
}
|
||||||
|
return RuntimeCatalog()
|
||||||
|
}
|
||||||
|
|||||||
67
internal/registry/catalog_test.go
Normal file
67
internal/registry/catalog_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
|
||||||
|
// restores them (with a full state reset) on cleanup.
|
||||||
|
func swapEmbeddedMeta(t *testing.T, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
resetInit()
|
||||||
|
orig := embeddedMetaJSON
|
||||||
|
embeddedMetaJSON = data
|
||||||
|
t.Cleanup(func() {
|
||||||
|
waitBackgroundRefresh()
|
||||||
|
embeddedMetaJSON = orig
|
||||||
|
resetInit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
|
||||||
|
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||||
|
|
||||||
|
c := SchemaCatalog()
|
||||||
|
|
||||||
|
if c.Source() != apicatalog.SourceEmbedded {
|
||||||
|
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
|
||||||
|
}
|
||||||
|
if _, ok := c.Service("embedded_svc"); !ok {
|
||||||
|
t.Fatal("expected embedded_svc from embedded metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
|
||||||
|
// from the bare Go module (plugin builds): only the empty meta_data_default.json
|
||||||
|
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
|
||||||
|
// Init seeds via sync fetch.
|
||||||
|
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
|
||||||
|
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write(testEnvelopeJSON("remote_svc"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
testMetaURL = ts.URL
|
||||||
|
|
||||||
|
c := SchemaCatalog()
|
||||||
|
|
||||||
|
if c.Source() != apicatalog.SourceRuntime {
|
||||||
|
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
|
||||||
|
}
|
||||||
|
if _, ok := c.Service("remote_svc"); !ok {
|
||||||
|
t.Fatal("expected remote_svc from runtime fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/meta"
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
"github.com/larksuite/cli/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed scope_priorities.json scope_overrides.json
|
//go:embed scope_priorities.json scope_overrides.json
|
||||||
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
|
|||||||
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
||||||
|
|
||||||
if !brandChanged {
|
if !brandChanged {
|
||||||
if cached, err := loadCachedMerged(); err == nil {
|
// After a CLI upgrade the embedded data can be fresher than an old
|
||||||
|
// cache; an equal/older cache must not shadow it.
|
||||||
|
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
|
||||||
overlayMergedServices(cached)
|
overlayMergedServices(cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
internal/registry/loader_test.go
Normal file
102
internal/registry/loader_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedCache writes a cache file + cache meta for one service whose Title is
|
||||||
|
// marker, tagged with the given top-level data version and brand.
|
||||||
|
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
|
||||||
|
t.Helper()
|
||||||
|
cDir := filepath.Join(dir, "cache")
|
||||||
|
if err := os.MkdirAll(cDir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := MergedRegistry{
|
||||||
|
Version: version,
|
||||||
|
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(reg)
|
||||||
|
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
|
||||||
|
mData, _ := json.Marshal(cm)
|
||||||
|
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
|
||||||
|
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
|
||||||
|
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
|
||||||
|
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
|
||||||
|
t.Helper()
|
||||||
|
embedded, _ := json.Marshal(MergedRegistry{
|
||||||
|
Version: embeddedVer,
|
||||||
|
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
|
||||||
|
})
|
||||||
|
swapEmbeddedMeta(t, embedded)
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
|
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
|
||||||
|
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
|
||||||
|
InitWithBrand(core.BrandFeishu)
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleOf(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
svc, ok := ServiceTyped(name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("service %q not loaded", name)
|
||||||
|
}
|
||||||
|
return svc.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "2.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("older cache: got %q, want EMBEDDED", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "2.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||||
|
t.Errorf("newer cache: got %q, want CACHE", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "not-a-semver")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
|
||||||
|
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
|
||||||
|
// plugin builds without compiled meta_data.json still get remote data.
|
||||||
|
initWithCache(t, "0.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||||
|
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testRegistry returns a minimal MergedRegistry with one service.
|
// testRegistry returns a minimal MergedRegistry with one service.
|
||||||
|
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
|
||||||
|
// so cache overlay passes the version gate in InitWithBrand.
|
||||||
func testRegistry(name string) MergedRegistry {
|
func testRegistry(name string) MergedRegistry {
|
||||||
return MergedRegistry{
|
return MergedRegistry{
|
||||||
Version: "test-1.0",
|
Version: "1.0.0",
|
||||||
Services: []meta.Service{
|
Services: []meta.Service{
|
||||||
{
|
{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheHit_WithinTTL(t *testing.T) {
|
func TestCacheHit_WithinTTL(t *testing.T) {
|
||||||
resetInit()
|
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNetworkError_SilentDegradation(t *testing.T) {
|
func TestNetworkError_SilentDegradation(t *testing.T) {
|
||||||
resetInit()
|
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
t.Fatal("expected non-nil data")
|
t.Fatal("expected non-nil data")
|
||||||
}
|
}
|
||||||
if reg.Version != "test-1.0" {
|
if reg.Version != "1.0.0" {
|
||||||
t.Errorf("expected version test-1.0, got %s", reg.Version)
|
t.Errorf("expected version 1.0.0, got %s", reg.Version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type InstallMethod int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
InstallNpm InstallMethod = iota
|
InstallNpm InstallMethod = iota
|
||||||
|
InstallPnpm
|
||||||
InstallManual
|
InstallManual
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,19 +57,29 @@ type DetectResult struct {
|
|||||||
Method InstallMethod
|
Method InstallMethod
|
||||||
ResolvedPath string
|
ResolvedPath string
|
||||||
NpmAvailable bool
|
NpmAvailable bool
|
||||||
|
PnpmAvailable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||||
func (d DetectResult) CanAutoUpdate() bool {
|
func (d DetectResult) CanAutoUpdate() bool {
|
||||||
return d.Method == InstallNpm && d.NpmAvailable
|
switch d.Method {
|
||||||
|
case InstallNpm:
|
||||||
|
return d.NpmAvailable
|
||||||
|
case InstallPnpm:
|
||||||
|
return d.PnpmAvailable
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||||
func (d DetectResult) ManualReason() string {
|
func (d DetectResult) ManualReason() string {
|
||||||
if d.Method == InstallNpm && !d.NpmAvailable {
|
switch {
|
||||||
|
case d.Method == InstallNpm && !d.NpmAvailable:
|
||||||
return "installed via npm, but npm is not available in PATH"
|
return "installed via npm, but npm is not available in PATH"
|
||||||
|
case d.Method == InstallPnpm && !d.PnpmAvailable:
|
||||||
|
return "installed via pnpm, but pnpm is not available in PATH"
|
||||||
}
|
}
|
||||||
return "not installed via npm"
|
return "not installed via npm or pnpm"
|
||||||
}
|
}
|
||||||
|
|
||||||
// NpmResult holds the result of an npm install or skills update execution.
|
// NpmResult holds the result of an npm install or skills update execution.
|
||||||
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
|
|||||||
type Updater struct {
|
type Updater struct {
|
||||||
DetectOverride func() DetectResult
|
DetectOverride func() DetectResult
|
||||||
NpmInstallOverride func(version string) *NpmResult
|
NpmInstallOverride func(version string) *NpmResult
|
||||||
|
PnpmInstallOverride func(version string) *NpmResult
|
||||||
SkillsIndexFetchOverride func() *NpmResult
|
SkillsIndexFetchOverride func() *NpmResult
|
||||||
SkillsCommandOverride func(args ...string) *NpmResult
|
SkillsCommandOverride func(args ...string) *NpmResult
|
||||||
VerifyOverride func(expectedVersion string) error
|
VerifyOverride func(expectedVersion string) error
|
||||||
@@ -101,17 +113,38 @@ type Updater struct {
|
|||||||
// running binary is successfully renamed to .old. Used by
|
// running binary is successfully renamed to .old. Used by
|
||||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||||
backupCreated bool
|
backupCreated bool
|
||||||
|
|
||||||
|
// detectCache memoizes the first real DetectInstallMethod result. How this
|
||||||
|
// binary was installed cannot change during a single process, so caching is
|
||||||
|
// the correct semantics — and it is required for correctness: the update
|
||||||
|
// flow mutates the install (pnpm add -g / npm install -g) before syncing
|
||||||
|
// skills, so a re-detection at skills time could resolve a now-stale
|
||||||
|
// os.Executable path and misclassify. Seeded pre-update by the first call
|
||||||
|
// (updateRun), it keeps the post-update skills launcher consistent with the
|
||||||
|
// launcher reported to the user. Not goroutine-safe; the update flow is
|
||||||
|
// sequential.
|
||||||
|
detectCache *DetectResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an Updater with default (real) behavior.
|
// New creates an Updater with default (real) behavior.
|
||||||
func New() *Updater { return &Updater{} }
|
func New() *Updater { return &Updater{} }
|
||||||
|
|
||||||
// DetectInstallMethod determines how the CLI was installed and whether
|
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||||
// npm is available for auto-update.
|
// owning package manager is available for auto-update.
|
||||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||||
if u.DetectOverride != nil {
|
if u.DetectOverride != nil {
|
||||||
return u.DetectOverride()
|
return u.DetectOverride()
|
||||||
}
|
}
|
||||||
|
if u.detectCache != nil {
|
||||||
|
return *u.detectCache
|
||||||
|
}
|
||||||
|
result := u.detectInstallMethod()
|
||||||
|
u.detectCache = &result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectInstallMethod performs the real (uncached) detection.
|
||||||
|
func (u *Updater) detectInstallMethod() DetectResult {
|
||||||
exe, err := vfs.Executable()
|
exe, err := vfs.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual}
|
return DetectResult{Method: InstallManual}
|
||||||
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
||||||
}
|
}
|
||||||
|
_, npmErr := exec.LookPath("npm")
|
||||||
|
_, pnpmErr := exec.LookPath("pnpm")
|
||||||
|
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectFromResolved classifies the resolved binary path into an install
|
||||||
|
// method and records package-manager availability. Split out from
|
||||||
|
// DetectInstallMethod so the classification is unit-testable without touching
|
||||||
|
// the filesystem or PATH.
|
||||||
|
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
|
||||||
method := InstallManual
|
method := InstallManual
|
||||||
if strings.Contains(resolved, "node_modules") {
|
if strings.Contains(resolved, "node_modules") {
|
||||||
|
if containsPnpmMarker(resolved) {
|
||||||
|
method = InstallPnpm
|
||||||
|
} else {
|
||||||
method = InstallNpm
|
method = InstallNpm
|
||||||
}
|
}
|
||||||
|
|
||||||
npmAvailable := false
|
|
||||||
if method == InstallNpm {
|
|
||||||
if _, err := exec.LookPath("npm"); err == nil {
|
|
||||||
npmAvailable = true
|
|
||||||
}
|
}
|
||||||
|
d := DetectResult{Method: method, ResolvedPath: resolved}
|
||||||
|
switch method {
|
||||||
|
case InstallNpm:
|
||||||
|
d.NpmAvailable = npmOnPath
|
||||||
|
case InstallPnpm:
|
||||||
|
d.PnpmAvailable = pnpmOnPath
|
||||||
|
}
|
||||||
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
return DetectResult{
|
// containsPnpmMarker reports whether the resolved binary path belongs to a
|
||||||
Method: method,
|
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
|
||||||
ResolvedPath: resolved,
|
// (a ".pnpm" directory segment) and the global content-addressable store,
|
||||||
NpmAvailable: npmAvailable,
|
// whose resolved path runs through pnpm's home directory (e.g.
|
||||||
|
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
|
||||||
|
// followed by "store". Matching only these two shapes (rather than any bare
|
||||||
|
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
|
||||||
|
// a directory named "pnpm". Windows separators are normalized to "/" so the
|
||||||
|
// classification is OS-independent and unit-testable anywhere.
|
||||||
|
func containsPnpmMarker(p string) bool {
|
||||||
|
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == ".pnpm" {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||||
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
|
||||||
|
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
|
||||||
|
if u.PnpmInstallOverride != nil {
|
||||||
|
return u.PnpmInstallOverride(version)
|
||||||
|
}
|
||||||
|
r := &NpmResult{}
|
||||||
|
pnpmPath, err := exec.LookPath("pnpm")
|
||||||
|
if err != nil {
|
||||||
|
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
|
||||||
|
cmd.Stdout = &r.Stdout
|
||||||
|
cmd.Stderr = &r.Stderr
|
||||||
|
r.Err = cmd.Run()
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||||
if u.SkillsIndexFetchOverride != nil {
|
if u.SkillsIndexFetchOverride != nil {
|
||||||
return u.SkillsIndexFetchOverride()
|
return u.SkillsIndexFetchOverride()
|
||||||
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
|
|||||||
return u.runSkillsCommand(args...)
|
return u.runSkillsCommand(args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
|
||||||
|
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
|
||||||
|
// pnpm-only environments (pnpm's standalone installer bundles Node without
|
||||||
|
// putting npm/npx on PATH) can still sync skills after a self-update.
|
||||||
|
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
|
||||||
|
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
|
||||||
|
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
|
||||||
|
// launcher selection is unit-testable on any platform.
|
||||||
|
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
|
||||||
|
if method == InstallPnpm && pnpmAvailable {
|
||||||
|
r := args
|
||||||
|
if len(r) > 0 && r[0] == "-y" {
|
||||||
|
r = r[1:]
|
||||||
|
}
|
||||||
|
return "pnpm", append([]string{"dlx"}, r...)
|
||||||
|
}
|
||||||
|
return "npx", args
|
||||||
|
}
|
||||||
|
|
||||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||||
if u.SkillsCommandOverride != nil {
|
if u.SkillsCommandOverride != nil {
|
||||||
return u.SkillsCommandOverride(args...)
|
return u.SkillsCommandOverride(args...)
|
||||||
}
|
}
|
||||||
r := &NpmResult{}
|
r := &NpmResult{}
|
||||||
npxPath, err := exec.LookPath("npx")
|
det := u.DetectInstallMethod()
|
||||||
|
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
|
||||||
|
binPath, err := exec.LookPath(launcher)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
||||||
cmd.Stdout = &r.Stdout
|
cmd.Stdout = &r.Stdout
|
||||||
cmd.Stderr = &r.Stderr
|
cmd.Stderr = &r.Stderr
|
||||||
r.Err = cmd.Run()
|
r.Err = cmd.Run()
|
||||||
|
|||||||
@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
|||||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContainsPnpmMarker(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Classic virtual-store layout (.pnpm segment).
|
||||||
|
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||||
|
// Global content-addressable store layout (pnpm 11): resolved path runs
|
||||||
|
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
|
||||||
|
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||||
|
// npm and non-package installs — no pnpm/.pnpm segment.
|
||||||
|
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
{"/usr/local/bin/lark-cli", false},
|
||||||
|
// Substrings that must NOT match: segment must be exactly .pnpm, or
|
||||||
|
// "pnpm" immediately followed by "store".
|
||||||
|
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
|
||||||
|
// living under a dir named pnpm) must not be misclassified as pnpm.
|
||||||
|
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := containsPnpmMarker(c.path); got != c.want {
|
||||||
|
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectInstallMethod_Pnpm(t *testing.T) {
|
||||||
|
u := &Updater{DetectOverride: nil}
|
||||||
|
u.DetectOverride = func() DetectResult {
|
||||||
|
// Exercise the real classification by feeding a resolved path via a small shim.
|
||||||
|
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
|
||||||
|
}
|
||||||
|
got := u.DetectInstallMethod()
|
||||||
|
if got.Method != InstallPnpm {
|
||||||
|
t.Errorf("Method = %v, want InstallPnpm", got.Method)
|
||||||
|
}
|
||||||
|
if !got.PnpmAvailable {
|
||||||
|
t.Errorf("PnpmAvailable = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
|
||||||
|
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
|
||||||
|
t.Errorf("npm path Method = %v, want InstallNpm", m)
|
||||||
|
}
|
||||||
|
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
|
||||||
|
t.Errorf("manual path Method = %v, want InstallManual", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanAutoUpdate_Pnpm(t *testing.T) {
|
||||||
|
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
|
||||||
|
t.Error("pnpm available should CanAutoUpdate")
|
||||||
|
}
|
||||||
|
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
|
||||||
|
t.Error("pnpm unavailable should not CanAutoUpdate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualReason_Pnpm(t *testing.T) {
|
||||||
|
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
|
||||||
|
t.Errorf("pnpm reason = %q", got)
|
||||||
|
}
|
||||||
|
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
|
||||||
|
t.Errorf("manual reason = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPnpmInstall_Override(t *testing.T) {
|
||||||
|
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
|
||||||
|
r := &NpmResult{}
|
||||||
|
r.Stdout.WriteString("added @larksuite/cli@" + version)
|
||||||
|
return r
|
||||||
|
}}
|
||||||
|
got := u.RunPnpmInstall("2.0.0")
|
||||||
|
if got.Err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", got.Err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
|
||||||
|
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPnpmInstall_Error(t *testing.T) {
|
||||||
|
wantErr := errors.New("boom")
|
||||||
|
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
|
||||||
|
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
|
||||||
|
t.Errorf("err = %v, want %v", got.Err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsInvocation(t *testing.T) {
|
||||||
|
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
method InstallMethod
|
||||||
|
pnpmAvailable bool
|
||||||
|
args []string
|
||||||
|
wantLauncher string
|
||||||
|
wantRest []string
|
||||||
|
}{
|
||||||
|
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
|
||||||
|
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
|
||||||
|
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
|
||||||
|
"npx", addArgs},
|
||||||
|
{"npm install → npx unchanged", InstallNpm, false, addArgs,
|
||||||
|
"npx", addArgs},
|
||||||
|
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
|
||||||
|
"npx", []string{"-y", "skills", "ls", "-g"}},
|
||||||
|
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
|
||||||
|
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
|
||||||
|
if gotLauncher != c.wantLauncher {
|
||||||
|
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
|
||||||
|
}
|
||||||
|
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
|
||||||
|
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
|
||||||
|
// hazard: DetectInstallMethod must return the first (pre-update) detection on
|
||||||
|
// subsequent calls, so the skills launcher chosen after the binary is replaced
|
||||||
|
// stays consistent with what was detected — and reported — before the update.
|
||||||
|
func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||||
|
u := New()
|
||||||
|
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
|
||||||
|
u.detectCache = &cached
|
||||||
|
got := u.DetectInstallMethod()
|
||||||
|
if got.Method != InstallPnpm || !got.PnpmAvailable {
|
||||||
|
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
209
internal/skillpolicy/overlay.go
Normal file
209
internal/skillpolicy/overlay.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package skillpolicy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// overlayFS layers an upper skill tree over a lower one at skill (top
|
||||||
|
// path segment) granularity: a skill present in upper is served wholly
|
||||||
|
// from upper, a skill named in removed is hidden, and everything else
|
||||||
|
// falls through to lower. It is the runtime form of a SkillsOverlay's
|
||||||
|
// Base -> Remove -> Overlay composition.
|
||||||
|
//
|
||||||
|
// It implements the io/fs fast-path interfaces (ReadDir/Stat/ReadFile)
|
||||||
|
// so the io/fs helpers route through the merge instead of hitting a
|
||||||
|
// single underlying tree.
|
||||||
|
type overlayFS struct {
|
||||||
|
lower fs.FS // base (or SkillsOverlay.Base); may be nil
|
||||||
|
upper fs.FS // SkillsOverlay.Overlay; may be nil
|
||||||
|
removed map[string]bool // skill names whited out from lower
|
||||||
|
upperNames map[string]bool // skill names owned by upper
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ fs.FS = (*overlayFS)(nil)
|
||||||
|
_ fs.ReadDirFS = (*overlayFS)(nil)
|
||||||
|
_ fs.StatFS = (*overlayFS)(nil)
|
||||||
|
_ fs.ReadFileFS = (*overlayFS)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func newOverlayFS(lower, upper fs.FS, remove []string) *overlayFS {
|
||||||
|
o := &overlayFS{
|
||||||
|
lower: lower,
|
||||||
|
upper: upper,
|
||||||
|
removed: make(map[string]bool, len(remove)),
|
||||||
|
upperNames: map[string]bool{},
|
||||||
|
}
|
||||||
|
for _, r := range remove {
|
||||||
|
o.removed[r] = true
|
||||||
|
}
|
||||||
|
if upper != nil {
|
||||||
|
if entries, err := fs.ReadDir(upper, "."); err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
o.upperNames[e.Name()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// route picks the tree owning name by its top path segment. whiteout is
|
||||||
|
// true when name belongs to a removed skill (present in neither tree).
|
||||||
|
func (o *overlayFS) route(name string) (target fs.FS, whiteout bool) {
|
||||||
|
top := name
|
||||||
|
if i := strings.IndexByte(name, '/'); i >= 0 {
|
||||||
|
top = name[:i]
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case o.upperNames[top]:
|
||||||
|
return o.upper, false
|
||||||
|
case o.removed[top]:
|
||||||
|
return nil, true
|
||||||
|
default:
|
||||||
|
return o.lower, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *overlayFS) Open(name string) (fs.File, error) {
|
||||||
|
if !fs.ValidPath(name) {
|
||||||
|
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
|
||||||
|
}
|
||||||
|
if name == "." {
|
||||||
|
entries, err := o.ReadDir(".")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rootDir{entries: entries}, nil
|
||||||
|
}
|
||||||
|
target, whiteout := o.route(name)
|
||||||
|
if whiteout || target == nil {
|
||||||
|
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||||
|
}
|
||||||
|
return target.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *overlayFS) Stat(name string) (fs.FileInfo, error) {
|
||||||
|
if !fs.ValidPath(name) {
|
||||||
|
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrInvalid}
|
||||||
|
}
|
||||||
|
if name == "." {
|
||||||
|
return rootInfo{}, nil
|
||||||
|
}
|
||||||
|
target, whiteout := o.route(name)
|
||||||
|
if whiteout || target == nil {
|
||||||
|
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
|
||||||
|
}
|
||||||
|
return fs.Stat(target, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *overlayFS) ReadFile(name string) ([]byte, error) {
|
||||||
|
if !fs.ValidPath(name) || name == "." {
|
||||||
|
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrInvalid}
|
||||||
|
}
|
||||||
|
target, whiteout := o.route(name)
|
||||||
|
if whiteout || target == nil {
|
||||||
|
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist}
|
||||||
|
}
|
||||||
|
return fs.ReadFile(target, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *overlayFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||||
|
if !fs.ValidPath(name) {
|
||||||
|
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid}
|
||||||
|
}
|
||||||
|
if name != "." {
|
||||||
|
target, whiteout := o.route(name)
|
||||||
|
if whiteout || target == nil {
|
||||||
|
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
|
||||||
|
}
|
||||||
|
return fs.ReadDir(target, name)
|
||||||
|
}
|
||||||
|
return o.mergedRoot()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergedRoot lists the top-level skills: every upper skill, plus every
|
||||||
|
// lower skill that is neither removed nor shadowed by a same-named upper.
|
||||||
|
// Sorted so listings are deterministic.
|
||||||
|
func (o *overlayFS) mergedRoot() ([]fs.DirEntry, error) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []fs.DirEntry
|
||||||
|
if o.upper != nil {
|
||||||
|
entries, err := fs.ReadDir(o.upper, ".")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
seen[e.Name()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o.lower != nil {
|
||||||
|
entries, err := fs.ReadDir(o.lower, ".")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if o.removed[e.Name()] || seen[e.Name()] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
seen[e.Name()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rootInfo is the synthetic FileInfo for the overlay's "." directory,
|
||||||
|
// which has no single backing entry in either tree.
|
||||||
|
type rootInfo struct{}
|
||||||
|
|
||||||
|
func (rootInfo) Name() string { return "." }
|
||||||
|
func (rootInfo) Size() int64 { return 0 }
|
||||||
|
func (rootInfo) Mode() fs.FileMode { return fs.ModeDir | 0o555 }
|
||||||
|
func (rootInfo) ModTime() time.Time { return time.Time{} }
|
||||||
|
func (rootInfo) IsDir() bool { return true }
|
||||||
|
func (rootInfo) Sys() any { return nil }
|
||||||
|
|
||||||
|
// rootDir is the synthetic directory file returned by Open(".").
|
||||||
|
type rootDir struct {
|
||||||
|
entries []fs.DirEntry
|
||||||
|
off int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *rootDir) Stat() (fs.FileInfo, error) { return rootInfo{}, nil }
|
||||||
|
func (d *rootDir) Close() error { return nil }
|
||||||
|
|
||||||
|
func (d *rootDir) Read([]byte) (int, error) {
|
||||||
|
return 0, &fs.PathError{Op: "read", Path: ".", Err: fs.ErrInvalid}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *rootDir) ReadDir(n int) ([]fs.DirEntry, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
rest := d.entries[d.off:]
|
||||||
|
d.off = len(d.entries)
|
||||||
|
return rest, nil
|
||||||
|
}
|
||||||
|
if d.off >= len(d.entries) {
|
||||||
|
return nil, io.EOF
|
||||||
|
}
|
||||||
|
end := d.off + n
|
||||||
|
if end > len(d.entries) {
|
||||||
|
end = len(d.entries)
|
||||||
|
}
|
||||||
|
part := d.entries[d.off:end]
|
||||||
|
d.off = end
|
||||||
|
return part, nil
|
||||||
|
}
|
||||||
156
internal/skillpolicy/resolver.go
Normal file
156
internal/skillpolicy/resolver.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package skillpolicy composes the CLI's effective embedded skill tree
|
||||||
|
// from a base skill FS and at most one plugin-supplied SkillsOverlay. It is
|
||||||
|
// the skill-side analogue of internal/cmdpolicy: plugins contribute a
|
||||||
|
// delta over a base, and one resolver produces the single tree that both
|
||||||
|
// skill readers consume -- `skills list`/`read` and the --help
|
||||||
|
// domain-guide pointer.
|
||||||
|
package skillpolicy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/extension/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginSkill pairs a plugin name with the SkillsOverlay it contributed, so a
|
||||||
|
// conflict can be attributed to specific owners. Mirrors
|
||||||
|
// cmdpolicy.PluginRule.
|
||||||
|
type PluginSkill struct {
|
||||||
|
PluginName string
|
||||||
|
SkillsOverlay *platform.SkillsOverlay
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrMultipleSkillsOverlays reports that more than one plugin tried to
|
||||||
|
// customize skill content. Mirrors cmdpolicy.ErrMultipleRestricts: only
|
||||||
|
// one owner is allowed so independent plugins cannot silently overwrite
|
||||||
|
// each other's skill tree.
|
||||||
|
var ErrMultipleSkillsOverlays = errors.New("multiple plugins customized skills; only one plugin may own skill content")
|
||||||
|
|
||||||
|
// Resolve composes the effective skill tree from base and the supplied
|
||||||
|
// specs. base is the CLI's embedded skill FS (nil when the build embeds
|
||||||
|
// none). With no spec it returns base unchanged. With exactly one spec it
|
||||||
|
// applies, in fixed order, Base override -> Allow -> Remove -> Overlay,
|
||||||
|
// returning an overlay FS in which a same-named skill resolves to
|
||||||
|
// Overlay. Two or more distinct owners is a configuration error.
|
||||||
|
func Resolve(base fs.FS, specs []PluginSkill) (fs.FS, error) {
|
||||||
|
owners := distinctOwners(specs)
|
||||||
|
if len(owners) > 1 {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrMultipleSkillsOverlays, owners)
|
||||||
|
}
|
||||||
|
if len(specs) == 0 || specs[0].SkillsOverlay == nil {
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, spec := specs[0].PluginName, specs[0].SkillsOverlay
|
||||||
|
lower := base
|
||||||
|
if spec.Base != nil {
|
||||||
|
lower = spec.Base
|
||||||
|
}
|
||||||
|
if err := validate(lower, spec); err != nil {
|
||||||
|
return nil, fmt.Errorf("plugin %q skill spec: %w", owner, err)
|
||||||
|
}
|
||||||
|
if lower == nil && spec.Overlay == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return newOverlayFS(lower, spec.Overlay, effectiveRemove(lower, spec)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveRemove folds a non-empty Allow into the remove set: every
|
||||||
|
// base skill not allow-listed is treated as removed, and explicit
|
||||||
|
// Remove entries win regardless (Remove-over-Allow, mirroring Rule's
|
||||||
|
// Deny-over-Allow). Overlay entries are never folded in — the overlay
|
||||||
|
// FS resolves them from the upper layer before the whiteout applies.
|
||||||
|
func effectiveRemove(lower fs.FS, spec *platform.SkillsOverlay) []string {
|
||||||
|
if len(spec.Allow) == 0 {
|
||||||
|
return spec.Remove
|
||||||
|
}
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
for _, name := range spec.Allow {
|
||||||
|
allowed[name] = true
|
||||||
|
}
|
||||||
|
removed := append([]string(nil), spec.Remove...)
|
||||||
|
entries, err := fs.ReadDir(lower, ".")
|
||||||
|
if err != nil {
|
||||||
|
return removed // validate already vetted lower; nothing more to fold
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() && !allowed[e.Name()] {
|
||||||
|
removed = append(removed, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
// distinctOwners returns the unique contributing plugin names in
|
||||||
|
// first-seen order. Mirrors cmdpolicy.distinctOwners.
|
||||||
|
func distinctOwners(specs []PluginSkill) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
owners := make([]string, 0, len(specs))
|
||||||
|
for _, s := range specs {
|
||||||
|
if !seen[s.PluginName] {
|
||||||
|
seen[s.PluginName] = true
|
||||||
|
owners = append(owners, s.PluginName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return owners
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate rejects a spec that cannot compose cleanly, so the failure
|
||||||
|
// surfaces at startup with a named cause rather than as a silently
|
||||||
|
// missing skill at read time.
|
||||||
|
func validate(lower fs.FS, spec *platform.SkillsOverlay) error {
|
||||||
|
for _, name := range spec.Allow {
|
||||||
|
if !isSkillName(name) {
|
||||||
|
return fmt.Errorf("Allow: %q is not a valid skill name", name)
|
||||||
|
}
|
||||||
|
if !skillExists(lower, name) {
|
||||||
|
return fmt.Errorf("Allow: skill %q is not in the base tree", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range spec.Remove {
|
||||||
|
if !isSkillName(name) {
|
||||||
|
return fmt.Errorf("Remove: %q is not a valid skill name", name)
|
||||||
|
}
|
||||||
|
if !skillExists(lower, name) {
|
||||||
|
return fmt.Errorf("Remove: skill %q is not in the base tree", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spec.Overlay != nil {
|
||||||
|
entries, err := fs.ReadDir(spec.Overlay, ".")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Overlay: cannot read root: %w", err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
return fmt.Errorf("Overlay: %q is not a directory; every Overlay entry must be a <skill>/ dir", e.Name())
|
||||||
|
}
|
||||||
|
if !skillExists(spec.Overlay, e.Name()) {
|
||||||
|
return fmt.Errorf("Overlay: skill %q is missing SKILL.md", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// skillExists reports whether fsys holds a skill named name -- a
|
||||||
|
// directory carrying SKILL.md, the shape internal/skillcontent treats as
|
||||||
|
// a skill.
|
||||||
|
func skillExists(fsys fs.FS, name string) bool {
|
||||||
|
if fsys == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
info, err := fs.Stat(fsys, name+"/SKILL.md")
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSkillName rejects empty, dotted, or path-bearing names so Remove
|
||||||
|
// cannot smuggle a traversal or match outside the top level.
|
||||||
|
func isSkillName(name string) bool {
|
||||||
|
return name != "" && name != "." && name != ".." && !strings.ContainsAny(name, `/\`)
|
||||||
|
}
|
||||||
272
internal/skillpolicy/resolver_test.go
Normal file
272
internal/skillpolicy/resolver_test.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package skillpolicy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/extension/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// skillFS builds an in-memory skill tree; each map entry is a path -> content.
|
||||||
|
func skillFS(files map[string]string) fstest.MapFS {
|
||||||
|
m := fstest.MapFS{}
|
||||||
|
for p, content := range files {
|
||||||
|
m[p] = &fstest.MapFile{Data: []byte(content)}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseTree is a representative base: three skills, one with a reference file.
|
||||||
|
func baseTree() fstest.MapFS {
|
||||||
|
return skillFS(map[string]string{
|
||||||
|
"lark-a/SKILL.md": "base a",
|
||||||
|
"lark-a/references/x.md": "base a ref",
|
||||||
|
"lark-b/SKILL.md": "base b",
|
||||||
|
"lark-shared/SKILL.md": "base shared",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// topLevel returns the sorted top-level skill names of fsys.
|
||||||
|
func topLevel(t *testing.T, fsys fs.FS) []string {
|
||||||
|
t.Helper()
|
||||||
|
entries, err := fs.ReadDir(fsys, ".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir(.): %v", err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(t *testing.T, fsys fs.FS, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := fs.ReadFile(fsys, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%q): %v", name, err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustResolve(t *testing.T, base fs.FS, spec *platform.SkillsOverlay) fs.FS {
|
||||||
|
t.Helper()
|
||||||
|
got, err := Resolve(base, []PluginSkill{{PluginName: "acme", SkillsOverlay: spec}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_NoSpecs_ReturnsBaseUnchanged(t *testing.T) {
|
||||||
|
base := baseTree()
|
||||||
|
got, err := Resolve(base, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve: %v", err)
|
||||||
|
}
|
||||||
|
if want := []string{"lark-a", "lark-b", "lark-shared"}; !equalStrings(topLevel(t, got), want) {
|
||||||
|
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_Remove_HidesSkill(t *testing.T) {
|
||||||
|
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Remove: []string{"lark-shared"}})
|
||||||
|
|
||||||
|
if want := []string{"lark-a", "lark-b"}; !equalStrings(topLevel(t, got), want) {
|
||||||
|
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||||
|
}
|
||||||
|
// The removed skill is gone for the affordance reader too (Stat gates it).
|
||||||
|
if _, err := fs.Stat(got, "lark-shared/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("Stat removed skill: err = %v, want ErrNotExist", err)
|
||||||
|
}
|
||||||
|
// Surviving skills still read from base.
|
||||||
|
if c := readFile(t, got, "lark-a/SKILL.md"); c != "base a" {
|
||||||
|
t.Errorf("lark-a content = %q, want %q", c, "base a")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_Overlay_AddsNewSkill(t *testing.T) {
|
||||||
|
overlay := skillFS(map[string]string{"lark-new/SKILL.md": "new skill"})
|
||||||
|
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Overlay: overlay})
|
||||||
|
|
||||||
|
if want := []string{"lark-a", "lark-b", "lark-new", "lark-shared"}; !equalStrings(topLevel(t, got), want) {
|
||||||
|
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||||
|
}
|
||||||
|
if c := readFile(t, got, "lark-new/SKILL.md"); c != "new skill" {
|
||||||
|
t.Errorf("lark-new content = %q, want %q", c, "new skill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_Overlay_ReplacesSameNameWholeSkill(t *testing.T) {
|
||||||
|
overlay := skillFS(map[string]string{"lark-a/SKILL.md": "overlaid a"})
|
||||||
|
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Overlay: overlay})
|
||||||
|
|
||||||
|
// Same-named skill resolves to the overlay (upper wins).
|
||||||
|
if c := readFile(t, got, "lark-a/SKILL.md"); c != "overlaid a" {
|
||||||
|
t.Errorf("lark-a content = %q, want overlaid", c)
|
||||||
|
}
|
||||||
|
// Replacement is whole-skill: the base's reference file is shadowed, not merged.
|
||||||
|
if _, err := fs.Stat(got, "lark-a/references/x.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("base reference should be shadowed by overlay skill; err = %v, want ErrNotExist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_Base_ReplacesEntireTree(t *testing.T) {
|
||||||
|
replacement := skillFS(map[string]string{"lark-only/SKILL.md": "only"})
|
||||||
|
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Base: replacement})
|
||||||
|
|
||||||
|
if want := []string{"lark-only"}; !equalStrings(topLevel(t, got), want) {
|
||||||
|
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat(got, "lark-a/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("base skill should be gone after Base replace; err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_Base_WithRemoveAndOverlay(t *testing.T) {
|
||||||
|
replacement := skillFS(map[string]string{
|
||||||
|
"lark-p/SKILL.md": "p",
|
||||||
|
"lark-q/SKILL.md": "q",
|
||||||
|
})
|
||||||
|
overlay := skillFS(map[string]string{"lark-r/SKILL.md": "r"})
|
||||||
|
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||||
|
Base: replacement,
|
||||||
|
Remove: []string{"lark-q"},
|
||||||
|
Overlay: overlay,
|
||||||
|
})
|
||||||
|
if want := []string{"lark-p", "lark-r"}; !equalStrings(topLevel(t, got), want) {
|
||||||
|
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow keeps only the listed base skills — the allow-list counterpart
|
||||||
|
// of Rule.Allow, so a CLI upgrade adding new embedded skills cannot
|
||||||
|
// widen an allow-listed build.
|
||||||
|
func TestResolve_Allow_KeepsOnlyListed(t *testing.T) {
|
||||||
|
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Allow: []string{"lark-a"}})
|
||||||
|
|
||||||
|
if want := []string{"lark-a"}; !equalStrings(topLevel(t, got), want) {
|
||||||
|
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat(got, "lark-b/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("non-allow-listed skill must be absent; err = %v", err)
|
||||||
|
}
|
||||||
|
// Kept skills still read from base, references included.
|
||||||
|
if c := readFile(t, got, "lark-a/references/x.md"); c != "base a ref" {
|
||||||
|
t.Errorf("kept skill content = %q, want base content", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove wins over Allow, mirroring Rule's Deny-over-Allow.
|
||||||
|
func TestResolve_RemoveWinsOverAllow(t *testing.T) {
|
||||||
|
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||||
|
Allow: []string{"lark-a", "lark-b"},
|
||||||
|
Remove: []string{"lark-b"},
|
||||||
|
})
|
||||||
|
if want := []string{"lark-a"}; !equalStrings(topLevel(t, got), want) {
|
||||||
|
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay entries are exempt from Allow: content the integrator
|
||||||
|
// explicitly ships needs no allow-listing.
|
||||||
|
func TestResolve_OverlayExemptFromAllow(t *testing.T) {
|
||||||
|
overlay := skillFS(map[string]string{"acme-guide/SKILL.md": "mine"})
|
||||||
|
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||||
|
Allow: []string{"lark-a"},
|
||||||
|
Overlay: overlay,
|
||||||
|
})
|
||||||
|
if want := []string{"acme-guide", "lark-a"}; !equalStrings(topLevel(t, got), want) {
|
||||||
|
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An Allow name absent from the base aborts startup, same as Remove.
|
||||||
|
func TestResolve_AllowUnknown_Errors(t *testing.T) {
|
||||||
|
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Allow: []string{"lark-nope"}}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error allow-listing a skill absent from base")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_RemoveUnknown_Errors(t *testing.T) {
|
||||||
|
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-nope"}}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error removing a skill absent from base")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_RemoveInvalidName_Errors(t *testing.T) {
|
||||||
|
for _, bad := range []string{"", ".", "..", "a/b", `a\b`} {
|
||||||
|
if _, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{bad}}}}); err == nil {
|
||||||
|
t.Errorf("Remove %q: expected error, got nil", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_OverlayMissingSKILLmd_Errors(t *testing.T) {
|
||||||
|
overlay := skillFS(map[string]string{"lark-bad/other.md": "no skill.md here"})
|
||||||
|
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for overlay entry missing SKILL.md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_OverlayNonDirEntry_Errors(t *testing.T) {
|
||||||
|
overlay := skillFS(map[string]string{"loose.md": "top-level file, not a skill dir"})
|
||||||
|
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-directory overlay entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_TwoOwners_Errors(t *testing.T) {
|
||||||
|
_, err := Resolve(baseTree(), []PluginSkill{
|
||||||
|
{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-a"}}},
|
||||||
|
{PluginName: "globex", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-b"}}},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrMultipleSkillsOverlays) {
|
||||||
|
t.Fatalf("err = %v, want ErrMultipleSkillsOverlays", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolve_ComposedConformsToFS runs the composed tree through the
|
||||||
|
// standard io/fs conformance checker to catch Open/ReadDir/Stat drift.
|
||||||
|
func TestResolve_ComposedConformsToFS(t *testing.T) {
|
||||||
|
overlay := skillFS(map[string]string{
|
||||||
|
"lark-new/SKILL.md": "new",
|
||||||
|
"lark-new/references/y.md": "new ref",
|
||||||
|
})
|
||||||
|
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||||
|
Remove: []string{"lark-shared"},
|
||||||
|
Overlay: overlay,
|
||||||
|
})
|
||||||
|
if err := fstest.TestFS(got,
|
||||||
|
"lark-a/SKILL.md",
|
||||||
|
"lark-a/references/x.md",
|
||||||
|
"lark-b/SKILL.md",
|
||||||
|
"lark-new/SKILL.md",
|
||||||
|
"lark-new/references/y.md",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("composed FS failed fs conformance: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStrings(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@larksuite/cli",
|
"name": "@larksuite/cli",
|
||||||
"version": "1.0.64",
|
"version": "1.0.67",
|
||||||
"description": "The official CLI for Lark/Feishu open platform",
|
"description": "The official CLI for Lark/Feishu open platform",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lark-cli": "scripts/run.js"
|
"lark-cli": "scripts/run.js"
|
||||||
|
|||||||
@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
|
||||||
|
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
|
||||||
|
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
|
||||||
|
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
|
||||||
|
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should explicitly skip when domain mode is skip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
|
||||||
|
! grep -Fq "id: e2e_domains" <<<"$section" ||
|
||||||
|
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
|
||||||
|
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
|
||||||
|
echo "e2e-live should use resolved live_packages instead of always running the full suite"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
|
||||||
|
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
|
||||||
|
echo "e2e-live should pass dynamic domain output through env before shell use"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! awk '
|
||||||
|
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||||
|
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||||
|
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||||
|
END { exit found ? 0 : 1 }
|
||||||
|
' <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! awk '
|
||||||
|
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||||
|
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||||
|
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||||
|
END { exit found ? 0 : 1 }
|
||||||
|
' <<<"$section"; then
|
||||||
|
echo "e2e-live should skip building lark-cli when domain mode is skip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if ! grep -Fq "permissions:" <<<"$section" ||
|
if ! grep -Fq "permissions:" <<<"$section" ||
|
||||||
! grep -Fq "contents: read" <<<"$section" ||
|
! grep -Fq "contents: read" <<<"$section" ||
|
||||||
! grep -Fq "checks: write" <<<"$section"; then
|
! grep -Fq "checks: write" <<<"$section"; then
|
||||||
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! awk '
|
||||||
|
/^ - name: Configure bot credentials/ { in_step = 1 }
|
||||||
|
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||||
|
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
|
||||||
|
END { exit found ? 0 : 1 }
|
||||||
|
' <<<"$section"; then
|
||||||
|
echo "e2e-live should only configure bot credentials when domain mode is not skip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
||||||
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
|
||||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
54
scripts/domain-map.js
Normal file
54
scripts/domain-map.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
|
||||||
|
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
|
||||||
|
|
||||||
|
function normalizeRepoPath(input) {
|
||||||
|
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
|
||||||
|
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
|
||||||
|
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||||
|
|
||||||
|
function findPathMapping(filePath) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelDomainsForPath(filePath) {
|
||||||
|
const mapping = findPathMapping(filePath);
|
||||||
|
return mapping ? [...(mapping.labelDomains || [])] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function e2eDomainsForPath(filePath) {
|
||||||
|
const mapping = findPathMapping(filePath);
|
||||||
|
return mapping ? [...(mapping.e2eDomains || [])] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFullFallback(filePath) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSkippablePath(filePath) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
const basename = path.posix.basename(normalized);
|
||||||
|
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|
||||||
|
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|
||||||
|
|| (domainMap.skipFilenames || []).includes(basename);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
domainMap,
|
||||||
|
e2eDomainsForPath,
|
||||||
|
findPathMapping,
|
||||||
|
isSkippablePath,
|
||||||
|
labelDomainsForPath,
|
||||||
|
matchesFullFallback,
|
||||||
|
normalizeRepoPath,
|
||||||
|
};
|
||||||
71
scripts/domain-map.json
Normal file
71
scripts/domain-map.json
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"pathMappings": [
|
||||||
|
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||||
|
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||||
|
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||||
|
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||||
|
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||||
|
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||||
|
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||||
|
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||||
|
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||||
|
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||||
|
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||||
|
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||||
|
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||||
|
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||||
|
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||||
|
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||||
|
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||||
|
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
|
||||||
|
|
||||||
|
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||||
|
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||||
|
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||||
|
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||||
|
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||||
|
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||||
|
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||||
|
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||||
|
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||||
|
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||||
|
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||||
|
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||||
|
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||||
|
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||||
|
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||||
|
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||||
|
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||||
|
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
|
||||||
|
],
|
||||||
|
"fullFallbackPrefixes": [
|
||||||
|
"shortcuts/common/",
|
||||||
|
"cmd/",
|
||||||
|
"internal/",
|
||||||
|
"pkg/",
|
||||||
|
"extension/",
|
||||||
|
"registry/",
|
||||||
|
"go.mod",
|
||||||
|
"go.sum",
|
||||||
|
"Makefile",
|
||||||
|
".github/workflows/",
|
||||||
|
"scripts/"
|
||||||
|
],
|
||||||
|
"skipPrefixes": [
|
||||||
|
"docs/",
|
||||||
|
".changeset/"
|
||||||
|
],
|
||||||
|
"skipSuffixes": [
|
||||||
|
".md",
|
||||||
|
".mdx",
|
||||||
|
".txt",
|
||||||
|
".rst"
|
||||||
|
],
|
||||||
|
"skipFilenames": [
|
||||||
|
"readme.md",
|
||||||
|
"readme.zh.md",
|
||||||
|
"changelog.md",
|
||||||
|
"license",
|
||||||
|
"cla.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
224
scripts/e2e_domains.js
Normal file
224
scripts/e2e_domains.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const {
|
||||||
|
e2eDomainsForPath,
|
||||||
|
findPathMapping,
|
||||||
|
isSkippablePath,
|
||||||
|
matchesFullFallback,
|
||||||
|
normalizeRepoPath,
|
||||||
|
} = require("./domain-map");
|
||||||
|
|
||||||
|
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
|
||||||
|
process.chdir(ROOT);
|
||||||
|
|
||||||
|
function execLines(command, args) {
|
||||||
|
return execFileSync(command, args, { encoding: "utf8" })
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modulePath() {
|
||||||
|
return execLines("go", ["list", "-m"])[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootPackage(moduleName) {
|
||||||
|
return `${moduleName}/tests/cli_e2e`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allLivePackages(moduleName) {
|
||||||
|
return execLines("go", ["list", "./tests/cli_e2e/..."])
|
||||||
|
.filter((pkg) => pkg !== rootPackage(moduleName))
|
||||||
|
.filter((pkg) => !pkg.endsWith("/demo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function allDryPackages(moduleName) {
|
||||||
|
return allLivePackages(moduleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainExistsCache = new Map();
|
||||||
|
|
||||||
|
function domainExists(domain) {
|
||||||
|
if (domainExistsCache.has(domain)) {
|
||||||
|
return domainExistsCache.get(domain);
|
||||||
|
}
|
||||||
|
let exists = false;
|
||||||
|
try {
|
||||||
|
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
|
||||||
|
exists = true;
|
||||||
|
} catch {
|
||||||
|
exists = false;
|
||||||
|
}
|
||||||
|
domainExistsCache.set(domain, exists);
|
||||||
|
return exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChangedFiles() {
|
||||||
|
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
|
||||||
|
if (changedFilesPath) {
|
||||||
|
return fs.readFileSync(changedFilesPath, "utf8")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(normalizeRepoPath)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseRef = process.env.GITHUB_BASE_REF || "main";
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
|
||||||
|
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDomain(domains, domain) {
|
||||||
|
if (domain && domainExists(domain)) {
|
||||||
|
domains.add(domain);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyPath(filePath, domains) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
if (!normalized) return { matched: false };
|
||||||
|
|
||||||
|
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
|
||||||
|
if (e2eMatch) {
|
||||||
|
const domain = e2eMatch[1];
|
||||||
|
if (domain === "demo") return { matched: false };
|
||||||
|
if (domainExists(domain)) {
|
||||||
|
addDomain(domains, domain);
|
||||||
|
return { matched: true };
|
||||||
|
}
|
||||||
|
if (isSkippablePath(normalized)) return { matched: false };
|
||||||
|
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("tests/cli_e2e/")) {
|
||||||
|
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesFullFallback(normalized)) {
|
||||||
|
return { fullReason: `shared/runtime path changed: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedDomains = e2eDomainsForPath(normalized);
|
||||||
|
if (mappedDomains.length > 0) {
|
||||||
|
const missingDomains = [];
|
||||||
|
for (const domain of mappedDomains) {
|
||||||
|
if (!addDomain(domains, domain)) missingDomains.push(domain);
|
||||||
|
}
|
||||||
|
if (missingDomains.length > 0) {
|
||||||
|
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
|
||||||
|
}
|
||||||
|
return { matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findPathMapping(normalized)) {
|
||||||
|
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
|
||||||
|
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSkippablePath(normalized)) return { matched: false };
|
||||||
|
|
||||||
|
return { fullReason: `unclassified path changed: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDomains(changedFiles) {
|
||||||
|
const moduleName = modulePath();
|
||||||
|
const rootDryPackage = rootPackage(moduleName);
|
||||||
|
if (changedFiles === null) {
|
||||||
|
return {
|
||||||
|
mode: "full",
|
||||||
|
reason: "non-pull_request run or unavailable diff",
|
||||||
|
domains: ["all"],
|
||||||
|
dryRootPackage: rootDryPackage,
|
||||||
|
dryPackages: allDryPackages(moduleName),
|
||||||
|
livePackages: allLivePackages(moduleName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = new Set();
|
||||||
|
let matchedRelevant = false;
|
||||||
|
let fullReason = "";
|
||||||
|
|
||||||
|
for (const file of changedFiles) {
|
||||||
|
const result = classifyPath(file, domains);
|
||||||
|
if (result.matched) matchedRelevant = true;
|
||||||
|
if (result.fullReason && !fullReason) fullReason = result.fullReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullReason) {
|
||||||
|
return {
|
||||||
|
mode: "full",
|
||||||
|
reason: fullReason,
|
||||||
|
domains: ["all"],
|
||||||
|
dryRootPackage: rootDryPackage,
|
||||||
|
dryPackages: allDryPackages(moduleName),
|
||||||
|
livePackages: allLivePackages(moduleName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedRelevant && domains.size > 0) {
|
||||||
|
const sortedDomains = [...domains].sort();
|
||||||
|
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
|
||||||
|
return {
|
||||||
|
mode: "subset",
|
||||||
|
reason: "business domain changes",
|
||||||
|
domains: sortedDomains,
|
||||||
|
dryRootPackage: rootDryPackage,
|
||||||
|
dryPackages: packages,
|
||||||
|
livePackages: packages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "skip",
|
||||||
|
reason: "docs-only or no live CLI E2E impact",
|
||||||
|
domains: [],
|
||||||
|
dryRootPackage: "",
|
||||||
|
dryPackages: [],
|
||||||
|
livePackages: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(resolved) {
|
||||||
|
const values = {
|
||||||
|
mode: resolved.mode,
|
||||||
|
reason: resolved.reason,
|
||||||
|
domains: resolved.domains.join(","),
|
||||||
|
dry_root_package: resolved.dryRootPackage,
|
||||||
|
dry_packages: resolved.dryPackages.join(" "),
|
||||||
|
live_packages: resolved.livePackages.join(" "),
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
||||||
|
console.log(lines.join("\n"));
|
||||||
|
|
||||||
|
if (process.env.GITHUB_OUTPUT) {
|
||||||
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
emit(resolveDomains(readChangedFiles()));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
classifyPath,
|
||||||
|
readChangedFiles,
|
||||||
|
resolveDomains,
|
||||||
|
};
|
||||||
94
scripts/e2e_domains.test.js
Normal file
94
scripts/e2e_domains.test.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const test = require("node:test");
|
||||||
|
|
||||||
|
const scriptPath = path.join(__dirname, "e2e_domains.js");
|
||||||
|
|
||||||
|
function parseOutput(raw) {
|
||||||
|
const result = {};
|
||||||
|
for (const line of raw.trim().split(/\r?\n/)) {
|
||||||
|
const idx = line.indexOf("=");
|
||||||
|
if (idx === -1) continue;
|
||||||
|
result[line.slice(0, idx)] = line.slice(idx + 1);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDomains(files) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
|
||||||
|
const file = path.join(dir, "changed.txt");
|
||||||
|
fs.writeFileSync(file, `${files.join("\n")}\n`);
|
||||||
|
try {
|
||||||
|
return parseOutput(execFileSync(process.execPath, [scriptPath], {
|
||||||
|
cwd: path.join(__dirname, ".."),
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("maps shortcut changes to one business domain package", () => {
|
||||||
|
const output = runDomains(["shortcuts/im/messages/send.go"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "im");
|
||||||
|
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
|
||||||
|
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps doc shortcuts to docs package", () => {
|
||||||
|
const output = runDomains(["shortcuts/doc/update.go"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "docs");
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps direct e2e domain package changes", () => {
|
||||||
|
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "drive");
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to full for shared e2e harness changes", () => {
|
||||||
|
const output = runDomains(["tests/cli_e2e/core.go"]);
|
||||||
|
assert.equal(output.mode, "full");
|
||||||
|
assert.equal(output.domains, "all");
|
||||||
|
assert.match(output.reason, /shared CLI E2E harness changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to full for runtime changes", () => {
|
||||||
|
const output = runDomains(["cmd/root.go"]);
|
||||||
|
assert.equal(output.mode, "full");
|
||||||
|
assert.equal(output.domains, "all");
|
||||||
|
assert.match(output.reason, /shared\/runtime path changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips docs-only changes", () => {
|
||||||
|
const output = runDomains(["docs/usage.md", "README.md"]);
|
||||||
|
assert.equal(output.mode, "skip");
|
||||||
|
assert.equal(output.domains, "");
|
||||||
|
assert.equal(output.dry_root_package, "");
|
||||||
|
assert.equal(output.live_packages, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses shared map for skill domain changes", () => {
|
||||||
|
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "sheets");
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to full when a mapped path has no e2e package", () => {
|
||||||
|
const output = runDomains(["shortcuts/whiteboard/export.go"]);
|
||||||
|
assert.equal(output.mode, "full");
|
||||||
|
assert.match(output.reason, /unmapped CLI E2E domain path/);
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
const fs = require("node:fs/promises");
|
const fs = require("node:fs/promises");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
const { labelDomainsForPath } = require("../domain-map");
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Constants & Configuration
|
// Constants & Configuration
|
||||||
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
|
|||||||
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
||||||
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
||||||
|
|
||||||
// CODEOWNERS-based path to domain label mapping
|
|
||||||
// Maps shortcuts and skills paths to business domain labels
|
|
||||||
const PATH_TO_DOMAIN_MAP = {
|
|
||||||
// shortcuts
|
|
||||||
"shortcuts/im/": "im",
|
|
||||||
"shortcuts/vc/": "vc",
|
|
||||||
"shortcuts/calendar/": "calendar",
|
|
||||||
"shortcuts/doc/": "ccm",
|
|
||||||
"shortcuts/sheets/": "ccm",
|
|
||||||
"shortcuts/drive/": "ccm",
|
|
||||||
"shortcuts/wiki/": "ccm",
|
|
||||||
"shortcuts/base/": "base",
|
|
||||||
"shortcuts/mail/": "mail",
|
|
||||||
"shortcuts/task/": "task",
|
|
||||||
"shortcuts/contact/": "contact",
|
|
||||||
// skills
|
|
||||||
"skills/lark-im/": "im",
|
|
||||||
"skills/lark-vc/": "vc",
|
|
||||||
"skills/lark-doc/": "ccm",
|
|
||||||
"skills/lark-wiki/": "ccm",
|
|
||||||
"skills/lark-base/": "base",
|
|
||||||
"skills/lark-mail/": "mail",
|
|
||||||
"skills/lark-calendar/": "calendar",
|
|
||||||
"skills/lark-task/": "task",
|
|
||||||
"skills/lark-contact/": "contact",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
||||||
|
|
||||||
const CLASS_STANDARDS = {
|
const CLASS_STANDARDS = {
|
||||||
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
|
|||||||
|
|
||||||
// Get business domain label based on CODEOWNERS path mapping
|
// Get business domain label based on CODEOWNERS path mapping
|
||||||
function getBusinessDomain(filePath) {
|
function getBusinessDomain(filePath) {
|
||||||
const normalized = normalizePath(filePath);
|
return labelDomainsForPath(filePath)[0] || "";
|
||||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
|
||||||
if (normalized.startsWith(prefix)) {
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectNewShortcutDomain(files) {
|
async function detectNewShortcutDomain(files) {
|
||||||
|
|||||||
@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||||||
script="$repo_root/scripts/resolve-changed-from.sh"
|
script="$repo_root/scripts/resolve-changed-from.sh"
|
||||||
|
|
||||||
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
|
||||||
|
cleanup_tmp() {
|
||||||
|
local attempt
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
rm -rf "$tmp" && return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup_tmp EXIT
|
||||||
mkdir -p "$tmp"
|
mkdir -p "$tmp"
|
||||||
|
|
||||||
git_init() {
|
git_init() {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
|
|||||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -145,7 +145,10 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
|||||||
existing := map[string]bool{}
|
existing := map[string]bool{}
|
||||||
token := ""
|
token := ""
|
||||||
for {
|
for {
|
||||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
params := map[string]interface{}{"page_size": 100}
|
||||||
|
if env != "" {
|
||||||
|
params["env"] = env
|
||||||
|
}
|
||||||
if token != "" {
|
if token != "" {
|
||||||
params["page_token"] = token
|
params["page_token"] = token
|
||||||
}
|
}
|
||||||
@@ -168,7 +171,11 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
|||||||
|
|
||||||
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
||||||
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
|
statusParams := map[string]interface{}{}
|
||||||
|
if env != "" {
|
||||||
|
statusParams["env"] = env
|
||||||
|
}
|
||||||
|
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -208,11 +215,10 @@ func auditListTables(rctx *common.RuntimeContext) []string {
|
|||||||
|
|
||||||
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
||||||
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
||||||
params := map[string]interface{}{
|
params := dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"tables": strings.Join(tables, ","),
|
"tables": strings.Join(tables, ","),
|
||||||
"page_size": rctx.Int("page-size"),
|
"page_size": rctx.Int("page-size"),
|
||||||
}
|
})
|
||||||
addStr := func(flag, key string) {
|
addStr := func(flag, key string) {
|
||||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||||
params[key] = v
|
params[key] = v
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
|||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Desc: "table to enable audit for", Required: true},
|
{Name: "table", Desc: "table to enable audit for", Required: true},
|
||||||
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
POST(appAuditSetPath(appID)).
|
POST(appAuditSetPath(appID)).
|
||||||
Desc("Enable table audit").
|
Desc("Enable table audit").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
|
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -60,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
|||||||
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
||||||
defer stop()
|
defer stop()
|
||||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||||
map[string]interface{}{"env": dbEnv(rctx)},
|
dbEnvParams(rctx, map[string]interface{}{}),
|
||||||
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
||||||
stop()
|
stop()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
|||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Desc: "table to disable audit for", Required: true},
|
{Name: "table", Desc: "table to disable audit for", Required: true},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
POST(appAuditSetPath(appID)).
|
POST(appAuditSetPath(appID)).
|
||||||
Desc("Disable table audit").
|
Desc("Disable table audit").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
|||||||
}
|
}
|
||||||
table := strings.TrimSpace(rctx.Str("table"))
|
table := strings.TrimSpace(rctx.Str("table"))
|
||||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||||
map[string]interface{}{"env": dbEnv(rctx)},
|
dbEnvParams(rctx, map[string]interface{}{}),
|
||||||
map[string]interface{}{"table": table, "enabled": false})
|
map[string]interface{}{"table": table, "enabled": false})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(err, dbAuditSetHint)
|
return withAppsHint(err, dbAuditSetHint)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
|||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
|||||||
|
|
||||||
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
||||||
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||||
params["table"] = t
|
params["table"] = t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
|
|||||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -77,10 +77,9 @@ var AppsDBChangelogList = common.Shortcut{
|
|||||||
|
|
||||||
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
||||||
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{
|
params := dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"page_size": rctx.Int("page-size"),
|
"page_size": rctx.Int("page-size"),
|
||||||
}
|
})
|
||||||
addStr := func(flag, key string) {
|
addStr := func(flag, key string) {
|
||||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||||
params[key] = v
|
params[key] = v
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
{Name: "table", Desc: "source table", Required: true},
|
{Name: "table", Desc: "source table", Required: true},
|
||||||
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
||||||
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
GET(appDataExportPath(appID)).
|
GET(appDataExportPath(appID)).
|
||||||
Desc("Export Miaoda app table data (raw bytes)").
|
Desc("Export Miaoda app table data (raw bytes)").
|
||||||
Params(map[string]interface{}{
|
Params(dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
|
"table": strings.TrimSpace(rctx.Str("table")),
|
||||||
"format": format, "limit": rctx.Int("limit"),
|
"format": format, "limit": rctx.Int("limit"),
|
||||||
})
|
}))
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
appID, err := requireAppID(rctx.Str("app-id"))
|
appID, err := requireAppID(rctx.Str("app-id"))
|
||||||
@@ -95,15 +95,18 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
||||||
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
||||||
|
|
||||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
exportQuery := larkcore.QueryParams{
|
||||||
HttpMethod: http.MethodGet,
|
|
||||||
ApiPath: appDataExportPath(appID),
|
|
||||||
QueryParams: larkcore.QueryParams{
|
|
||||||
"env": []string{dbEnv(rctx)},
|
|
||||||
"table": []string{table},
|
"table": []string{table},
|
||||||
"format": []string{format},
|
"format": []string{format},
|
||||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||||
},
|
}
|
||||||
|
if env := dbEnv(rctx); env != "" {
|
||||||
|
exportQuery["env"] = []string{env}
|
||||||
|
}
|
||||||
|
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||||
|
HttpMethod: http.MethodGet,
|
||||||
|
ApiPath: appDataExportPath(appID),
|
||||||
|
QueryParams: exportQuery,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
||||||
@@ -157,8 +160,11 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
||||||
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
||||||
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
||||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
|
params := map[string]interface{}{"page_size": 1}
|
||||||
map[string]interface{}{"env": env, "page_size": 1}, nil)
|
if env != "" {
|
||||||
|
params["env"] = env
|
||||||
|
}
|
||||||
|
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
|
|||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
|
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
|
||||||
{Name: "table", Desc: "target table (default: file name without extension)"},
|
{Name: "table", Desc: "target table (default: file name without extension)"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
POST(appDataImportPath(appID)).
|
POST(appDataImportPath(appID)).
|
||||||
Desc("Import data file into Miaoda app table (multipart upload)").
|
Desc("Import data file into Miaoda app table (multipart upload)").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
|
Params(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
|
||||||
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -100,10 +100,14 @@ var AppsDBDataImport = common.Shortcut{
|
|||||||
fd.AddField("file_name", fileName)
|
fd.AddField("file_name", fileName)
|
||||||
fd.AddFile("file", bytes.NewReader(content))
|
fd.AddFile("file", bytes.NewReader(content))
|
||||||
|
|
||||||
|
importQuery := larkcore.QueryParams{"table": []string{table}}
|
||||||
|
if env := dbEnv(rctx); env != "" {
|
||||||
|
importQuery["env"] = []string{env}
|
||||||
|
}
|
||||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||||
HttpMethod: http.MethodPost,
|
HttpMethod: http.MethodPost,
|
||||||
ApiPath: appDataImportPath(appID),
|
ApiPath: appDataImportPath(appID),
|
||||||
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
|
QueryParams: importQuery,
|
||||||
Body: fd,
|
Body: fd,
|
||||||
}, larkcore.WithFileUpload())
|
}, larkcore.WithFileUpload())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user