mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 02:14:02 +08:00
Compare commits
54 Commits
feat/slide
...
v1.0.67
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7db899db01 | ||
|
|
c2d6038aae | ||
|
|
efa3439e01 | ||
|
|
9f150670f3 | ||
|
|
578e2db4e0 | ||
|
|
94139751d3 | ||
|
|
8c3ed5d224 | ||
|
|
c982df4cf0 | ||
|
|
fb5ae41bca | ||
|
|
87e872a4c1 | ||
|
|
ddc0f2a521 | ||
|
|
440867f1b4 | ||
|
|
d0cde9a414 | ||
|
|
075b34f9a3 | ||
|
|
3788405256 | ||
|
|
462358a746 | ||
|
|
ad4d3cb874 | ||
|
|
171778951d | ||
|
|
a6797ac2e4 | ||
|
|
d852ab311b | ||
|
|
e8bfbab4a5 | ||
|
|
3bda9e17de | ||
|
|
e753b15d84 | ||
|
|
bdffffb368 | ||
|
|
ec6fdc9b30 | ||
|
|
775ee5a501 | ||
|
|
214318aa02 | ||
|
|
6f2cddfce1 |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -263,13 +263,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
@@ -277,7 +283,28 @@ jobs:
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
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:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
@@ -292,15 +319,22 @@ jobs:
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: |
|
||||
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"
|
||||
@@ -310,16 +344,24 @@ jobs:
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
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: |
|
||||
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
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
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 domains: $E2E_MODE ($E2E_REASON)"
|
||||
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
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,6 +27,9 @@ Thumbs.db
|
||||
# Go
|
||||
docs/ref
|
||||
docs/
|
||||
!tests/cli_e2e/docs/
|
||||
!tests/cli_e2e/docs/*.go
|
||||
!tests/cli_e2e/docs/*.md
|
||||
vendor/
|
||||
|
||||
|
||||
|
||||
128
CHANGELOG.md
128
CHANGELOG.md
@@ -2,6 +2,128 @@
|
||||
|
||||
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
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
|
||||
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
|
||||
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
|
||||
- **cli**: Improve secure label error handling (#1707)
|
||||
- **cli**: Reduce public content token false positives
|
||||
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
|
||||
- **doc**: Align word statistics compound tokens (#1706)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
|
||||
- **doc**: Support `reference_map` in docs (#1690)
|
||||
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
|
||||
|
||||
## [v1.0.62] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Add meeting message send shortcut (#1643)
|
||||
- **doc**: Add document word statistics helper (#1697)
|
||||
- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498)
|
||||
- **install**: Fail closed when `checksums.txt` is missing during install (#1503)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Improve batch failure handling for push/pull/sync (#1703)
|
||||
- **base**: Support JSON array input for field create (#1661)
|
||||
- **task**: Expose completion state in `my tasks` output (#1641)
|
||||
- **cli**: Reduce public content credential false positives (#1700)
|
||||
|
||||
## [v1.0.61] - 2026-06-30
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
|
||||
- **identity**: Add `whoami` command showing effective identity (#1666)
|
||||
- **docs**: Add reference map flags (#1547)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **identity**: Correct identity diagnosis under external credential providers (#1693)
|
||||
- **cli**: Harden git credential error handling (#1676)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Guide document copy skill usage (#1673)
|
||||
- **doc**: Fix lark-doc media token examples (#1662)
|
||||
|
||||
## [v1.0.60] - 2026-06-29
|
||||
|
||||
### Features
|
||||
@@ -1299,6 +1421,12 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- 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.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
|
||||
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||
|
||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(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.
|
||||
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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
```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 # 逗号分隔值
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
@@ -10,18 +10,33 @@ step. Maintain these files alongside `skills/` and `shortcuts/`.
|
||||
A small, fixed markdown subset; each file describes one domain:
|
||||
|
||||
# <domain> optional `> skill: <name>` applies to every command below
|
||||
## <command> the command as typed, minus `lark-cli <domain>`
|
||||
## <command> the command as typed, minus `lark-cli <domain>`; a
|
||||
+-prefixed heading (## +create) targets that shortcut
|
||||
<lead paragraph> when to use this command
|
||||
### Avoid when when not to use it / which command to use instead
|
||||
### Prerequisites what you must have first (e.g. an id, and where it comes from)
|
||||
### Tips gotchas and constraints
|
||||
### Examples **description** lines, each followed by a fenced command
|
||||
### 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
|
||||
|
||||
Reference another command with `[[command]]` — it renders as `command` in help.
|
||||
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
||||
("… from [[command]]") it means "get the input there first".
|
||||
|
||||
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
|
||||
|
||||
## 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.
|
||||
- Command-form headings resolve to method ids via the registry, so plural resource
|
||||
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
|
||||
> 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
|
||||
Bulk-fetch personal status and signature for user ids you already have.
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -20,13 +24,28 @@ import (
|
||||
"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) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -54,7 +73,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
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"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -77,7 +96,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
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"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
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"}},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -125,7 +144,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET"}) // missing path
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -138,7 +157,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
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"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -151,7 +170,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
fn := cmd.ValidArgsFunction
|
||||
|
||||
tests := []struct {
|
||||
@@ -217,7 +236,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
@@ -236,7 +255,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -255,7 +274,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
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", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -272,7 +291,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return apiRun(opts)
|
||||
})
|
||||
@@ -297,7 +316,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
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"})
|
||||
err := cmd.Execute()
|
||||
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"})
|
||||
err := cmd.Execute()
|
||||
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"})
|
||||
err := cmd.Execute()
|
||||
// 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"})
|
||||
err := cmd.Execute()
|
||||
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"})
|
||||
err := cmd.Execute()
|
||||
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"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -549,8 +568,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -600,8 +619,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -656,8 +675,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
@@ -721,7 +740,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -741,7 +760,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -760,7 +779,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
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)
|
||||
})
|
||||
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"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -812,7 +831,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
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)
|
||||
})
|
||||
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,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
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"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -880,7 +899,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -899,7 +918,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -917,7 +936,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
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)
|
||||
})
|
||||
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{
|
||||
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)
|
||||
})
|
||||
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{
|
||||
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)
|
||||
})
|
||||
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{
|
||||
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"})
|
||||
err := cmd.Execute()
|
||||
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"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -1041,7 +1060,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -1054,3 +1073,157 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,9 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
groupRootCommands(rootCmd)
|
||||
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
|
||||
// before printing help; non-bare invocations and non-TTY are unaffected.
|
||||
installRootUpgradePrompt(f, rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
|
||||
@@ -129,7 +129,10 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
// No hint: this only summarizes the two checks above, which already carry
|
||||
// the source-appropriate remediation. A command here would be redundant,
|
||||
// or wrong (`auth status` is blocked under an external provider).
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
|
||||
}
|
||||
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
||||
@@ -140,14 +145,84 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||
t.Helper()
|
||||
if got := findCheck(t, checks, name); got.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
|
||||
}
|
||||
}
|
||||
|
||||
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.Name == name {
|
||||
if check.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
||||
}
|
||||
return
|
||||
return check
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %#v", name, checks)
|
||||
return checkResult{}
|
||||
}
|
||||
|
||||
type fakeExtProvider struct {
|
||||
name string
|
||||
account *extcred.Account
|
||||
}
|
||||
|
||||
func (p *fakeExtProvider) Name() string { return p.name }
|
||||
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return p.account, nil
|
||||
}
|
||||
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Under an external credential provider with no usable identity, the
|
||||
// identity_ready hint must not point at `auth status` (blocked there); the
|
||||
// per-identity checks already carry the source-appropriate escalation.
|
||||
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
// Provider serves neither identity: bot unsupported, user supported but not
|
||||
// signed in → both unavailable → identity_ready fails.
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) { return nil, nil },
|
||||
)
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
|
||||
}
|
||||
var got struct {
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
|
||||
}
|
||||
|
||||
ready := findCheck(t, got.Checks, "identity_ready")
|
||||
if ready.Status != "fail" {
|
||||
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
|
||||
}
|
||||
// The summary defers to the per-identity checks; it carries no hint of its
|
||||
// own (a command here would be wrong under an external provider).
|
||||
if ready.Hint != "" {
|
||||
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
|
||||
}
|
||||
user := findCheck(t, got.Checks, "user_identity")
|
||||
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
|
||||
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,7 +679,11 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareMethodHelp(cmd) {
|
||||
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ import (
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"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 {
|
||||
t.Helper()
|
||||
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
if catalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||
} else {
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
}
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
||||
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) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
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{
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
90
cmd/root_upgrade.go
Normal file
90
cmd/root_upgrade.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// runRootUpgrade locates the registered `update` subcommand and runs it, so the
|
||||
// interactive root-command upgrade reuses exactly `lark-cli update` behavior
|
||||
// (install-method detection, output, error handling). Package-level var so
|
||||
// tests can stub it and avoid real network / self-update.
|
||||
var runRootUpgrade = func(cmd *cobra.Command) {
|
||||
for _, c := range cmd.Root().Commands() {
|
||||
if c.Name() == "update" && c.RunE != nil {
|
||||
_ = c.RunE(c, nil) // update prints its own output/errors; swallow here
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
|
||||
// no flags) — the only invocation that triggers the interactive upgrade prompt.
|
||||
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
|
||||
// AND no flag tokens in the raw invocation.
|
||||
func isBareRootInvocation(args []string) bool {
|
||||
return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0
|
||||
}
|
||||
|
||||
// readYes reads one line and reports whether it is an affirmative y/yes.
|
||||
// EOF / empty / anything else → false (default No, matching the [y/N] prompt).
|
||||
func readYes(r io.Reader) bool {
|
||||
line, _ := bufio.NewReader(r).ReadString('\n')
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "y", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// offerRootUpgrade prompts for an interactive upgrade when running bare
|
||||
// `lark-cli` in an interactive terminal with a cached newer version. Every
|
||||
// failure is swallowed — it must never affect help output or the exit code.
|
||||
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
ios := f.IOStreams
|
||||
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
|
||||
// stdout TTY too so this only fires in a pure foreground terminal session.
|
||||
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
|
||||
return
|
||||
}
|
||||
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
|
||||
// and the IsNewer/semver validation chain; it reads the on-disk cache that
|
||||
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
|
||||
info := update.CheckCached(build.Version)
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
|
||||
if !readYes(ios.In) {
|
||||
return
|
||||
}
|
||||
runRootUpgrade(cmd)
|
||||
}
|
||||
|
||||
// installRootUpgradePrompt wraps the root command's RunE (set to
|
||||
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
|
||||
// invocation offers an interactive upgrade before printing help. Non-bare
|
||||
// invocations are passed straight through, unchanged.
|
||||
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
|
||||
inner := root.RunE
|
||||
if inner == nil {
|
||||
return
|
||||
}
|
||||
root.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if isBareRootInvocation(args) {
|
||||
offerRootUpgrade(f, cmd)
|
||||
}
|
||||
return inner(cmd, args)
|
||||
}
|
||||
}
|
||||
191
cmd/root_upgrade_test.go
Normal file
191
cmd/root_upgrade_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func writeUpdateState(t *testing.T, dir, latest string) {
|
||||
t.Helper()
|
||||
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
|
||||
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadYes(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true,
|
||||
"n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := readYes(strings.NewReader(in)); got != want {
|
||||
t.Errorf("readYes(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBareRootInvocation(t *testing.T) {
|
||||
orig := rawInvocationArgs
|
||||
t.Cleanup(func() { rawInvocationArgs = orig })
|
||||
|
||||
rawInvocationArgs = nil
|
||||
if !isBareRootInvocation([]string{}) {
|
||||
t.Error("empty args + no raw flag tokens should be bare")
|
||||
}
|
||||
rawInvocationArgs = []string{"--profile", "x"}
|
||||
if isBareRootInvocation([]string{}) {
|
||||
t.Error("flag token present → not bare")
|
||||
}
|
||||
rawInvocationArgs = nil
|
||||
if isBareRootInvocation([]string{"im"}) {
|
||||
t.Error("positional arg → not bare")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfferRootUpgrade(t *testing.T) {
|
||||
origV := build.Version
|
||||
build.Version = "1.0.0" // release version so shouldSkip()==false
|
||||
t.Cleanup(func() { build.Version = origV })
|
||||
|
||||
origRun := runRootUpgrade
|
||||
t.Cleanup(func() { runRootUpgrade = origRun })
|
||||
|
||||
// This test builds a Factory literal (no NewDefault), so it never runs
|
||||
// workspace detection; pin the process-global workspace to Local so
|
||||
// statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale
|
||||
// subdir inherited from a prior test in the package.
|
||||
origWS := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in, out, err bool
|
||||
input string
|
||||
latest string // "" → no state file (CheckCached nil)
|
||||
optOut bool
|
||||
wantPrompt, wantRun bool
|
||||
}{
|
||||
{"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true},
|
||||
{"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true},
|
||||
{"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false},
|
||||
{"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false},
|
||||
{"all-tty+eof", true, true, true, "", "2.0.0", false, true, false},
|
||||
{"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false},
|
||||
{"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false},
|
||||
{"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false},
|
||||
{"no-newer-version", true, true, true, "y\n", "", false, false, false},
|
||||
{"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt
|
||||
{"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false},
|
||||
{"opt-out", true, true, true, "y\n", "2.0.0", true, false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Clear env that update.shouldSkip treats as "suppress" so the
|
||||
// test is deterministic regardless of host (GitHub Actions sets
|
||||
// CI=true, which would otherwise suppress the prompt).
|
||||
t.Setenv("CI", "")
|
||||
t.Setenv("BUILD_NUMBER", "")
|
||||
t.Setenv("RUN_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
|
||||
if tc.latest != "" {
|
||||
writeUpdateState(t, dir, tc.latest)
|
||||
}
|
||||
if tc.optOut {
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
}
|
||||
called := false
|
||||
runRootUpgrade = func(*cobra.Command) { called = true }
|
||||
|
||||
var errBuf bytes.Buffer
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(tc.input),
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &errBuf,
|
||||
IsTerminal: tc.in,
|
||||
OutIsTerminal: tc.out,
|
||||
StderrIsTerminal: tc.err,
|
||||
}}
|
||||
offerRootUpgrade(f, &cobra.Command{})
|
||||
|
||||
gotPrompt := strings.Contains(errBuf.String(), "available")
|
||||
if gotPrompt != tc.wantPrompt {
|
||||
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
|
||||
}
|
||||
if called != tc.wantRun {
|
||||
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
|
||||
orig := rawInvocationArgs
|
||||
t.Cleanup(func() { rawInvocationArgs = orig })
|
||||
rawInvocationArgs = nil
|
||||
|
||||
innerCalls := 0
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil }
|
||||
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root)
|
||||
|
||||
if err := root.RunE(root, []string{}); err != nil {
|
||||
t.Fatalf("bare RunE err = %v", err)
|
||||
}
|
||||
if err := root.RunE(root, []string{"im"}); err != nil {
|
||||
t.Fatalf("non-bare RunE err = %v", err)
|
||||
}
|
||||
if innerCalls != 2 {
|
||||
t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch
|
||||
// path (not the stub used elsewhere): from any command it must locate the
|
||||
// registered "update" subcommand via cmd.Root() and invoke its RunE.
|
||||
func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
ran := 0
|
||||
root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }})
|
||||
child := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(child)
|
||||
|
||||
runRootUpgrade(child) // child.Root() resolves to root, which has "update"
|
||||
|
||||
if ran != 1 {
|
||||
t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard:
|
||||
// when root has no RunE, installRootUpgradePrompt must not wrap it.
|
||||
func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"} // RunE is nil
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root)
|
||||
if root.RunE != nil {
|
||||
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
|
||||
}
|
||||
}
|
||||
@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
||||
// It uses the embedded source so completion candidates match what `schema`
|
||||
// execution can resolve (both overlay-free).
|
||||
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
|
||||
// It uses the same source as schema execution so completion candidates match
|
||||
// what `schema` can resolve.
|
||||
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) {
|
||||
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
|
||||
if noSpace {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
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
|
||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||
// output shape — a single resolved method renders as one envelope object,
|
||||
// anything broader as an array — and maps resolve failures to hints.
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
// hand-authored Long when present, else the Short — captured once into an
|
||||
// annotation so re-rendering reuses the pristine text instead of the
|
||||
// already-augmented Long.
|
||||
// hand-authored Long when present, else the Short.
|
||||
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
|
||||
}
|
||||
base := cmd.Long
|
||||
@@ -85,7 +92,7 @@ func domainHelpBase(cmd *cobra.Command) string {
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[domainBaseAnnotation] = base
|
||||
cmd.Annotations[key] = 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.
|
||||
// The affordance overlay coordinates live in cmdmeta (shared with shortcuts).
|
||||
const (
|
||||
affordanceServiceAnnotation = "affordance-service"
|
||||
affordanceMethodAnnotation = "affordance-method"
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
shortcutBaseAnnotation = "affordance-shortcut-base"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
if service != "" && methodID != "" {
|
||||
cmd.Annotations[affordanceServiceAnnotation] = service
|
||||
cmd.Annotations[affordanceMethodAnnotation] = methodID
|
||||
}
|
||||
cmdmeta.SetAffordanceRef(cmd, service, methodID)
|
||||
cmd.Annotations[schemaPathAnnotation] = schemaPath
|
||||
if 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
|
||||
// guidance at the TOP (Risk, then the affordance block, then the schema
|
||||
// pointer), returning false for non-method commands. The overlay is parsed
|
||||
// here — only when help is rendered.
|
||||
func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
// here — only when help is rendered. skillFS (nil-safe) gates the related-skill
|
||||
// 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
|
||||
if ann == nil {
|
||||
return false
|
||||
@@ -141,22 +148,15 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(cmd.Short)
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
// --yes asserts the USER confirmed; the agent must not self-approve.
|
||||
if level == cmdutil.RiskHighRiskWrite {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s", level)
|
||||
}
|
||||
}
|
||||
writeRisk(&b, cmd)
|
||||
|
||||
var skills []string
|
||||
if raw, ok := affordanceRaw(cmd); ok {
|
||||
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
|
||||
if block := renderAffordanceValue(a); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
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)
|
||||
b.WriteString(ann[paramsOnlyAnnotation])
|
||||
|
||||
if len(skills) > 0 {
|
||||
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
|
||||
for _, s := range skills {
|
||||
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
|
||||
}
|
||||
}
|
||||
writeRelatedSkills(&b, skills, skillFS)
|
||||
|
||||
cmd.Long = b.String()
|
||||
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.
|
||||
var affordanceLookup = affordance.For
|
||||
|
||||
@@ -189,12 +267,8 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string {
|
||||
}
|
||||
|
||||
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
|
||||
if cmd.Annotations == nil {
|
||||
return nil, false
|
||||
}
|
||||
service := cmd.Annotations[affordanceServiceAnnotation]
|
||||
methodID := cmd.Annotations[affordanceMethodAnnotation]
|
||||
if service == "" || methodID == "" {
|
||||
service, methodID, ok := cmdmeta.AffordanceRef(cmd)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return affordanceLookup(service, methodID)
|
||||
@@ -207,7 +281,13 @@ func renderAffordance(m meta.Method) string {
|
||||
if !ok {
|
||||
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
|
||||
bullets := func(title string, items []string) {
|
||||
var nonEmpty []string
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"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)
|
||||
}
|
||||
// The lookup ref is recorded so the help path can resolve it later.
|
||||
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
|
||||
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
|
||||
if svc, method, ok := cmdmeta.AffordanceRef(cmd); !ok || svc != "im" || method != "messages.create" {
|
||||
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": "发送消息"}
|
||||
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")
|
||||
}
|
||||
long := cmd.Long
|
||||
@@ -136,11 +137,133 @@ func TestPrepareMethodHelp(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// shape PrepareDomainHelp expects.
|
||||
func domainCmd(short, long string) *cobra.Command {
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
|
||||
@@ -102,7 +102,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: `Update lark-cli to the latest version.
|
||||
|
||||
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
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
if !detect.CanAutoUpdate() {
|
||||
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 ---
|
||||
@@ -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, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
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)
|
||||
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)
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
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()
|
||||
if err != nil {
|
||||
return reportError(opts, io, "update_error",
|
||||
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
}
|
||||
|
||||
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 {
|
||||
restore()
|
||||
combined := npmResult.CombinedOutput()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, 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),
|
||||
"hint": permissionHint(combined),
|
||||
"hint": permissionHint(combined, pm),
|
||||
},
|
||||
})
|
||||
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.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)
|
||||
}
|
||||
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 {
|
||||
restore()
|
||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||
hint := verificationFailureHint(updater, latest)
|
||||
hint := verificationFailureHint(updater, latest, pm)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"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, " Changelog: %s\n", changelogURL())
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionHint(npmOutput string) string {
|
||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
func permissionHint(pmOutput, pm string) string {
|
||||
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
||||
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() {
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
||||
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 {
|
||||
return func() *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) {
|
||||
tests := []struct {
|
||||
input string
|
||||
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
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) {
|
||||
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
|
||||
origOS := currentOS
|
||||
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"
|
||||
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") {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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).
|
||||
currentOS = "windows"
|
||||
hint = permissionHint("EACCES: permission denied")
|
||||
hint = permissionHint("EACCES: permission denied", "npm")
|
||||
if hint != "" {
|
||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||
}
|
||||
|
||||
// Non-EACCES error: always empty.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ package whoami
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -17,6 +15,13 @@ import (
|
||||
)
|
||||
|
||||
// whoamiResult is the structured output of `lark-cli whoami`.
|
||||
//
|
||||
// The self-vs-delegated distinction is carried by `identity`: a bot identity is
|
||||
// the app acting as itself; a user identity is the app acting *on behalf of* a
|
||||
// person (calls are attributed to that user, who is not necessarily present).
|
||||
// onBehalfOf only *names* that person and so appears only once a user is
|
||||
// resolved — a user identity that is not signed in still has identity "user"
|
||||
// but no onBehalfOf yet. Do not read "no onBehalfOf" as "self"; read `identity`.
|
||||
type whoamiResult struct {
|
||||
Profile string `json:"profile"`
|
||||
AppID string `json:"appId"`
|
||||
@@ -26,34 +31,44 @@ type whoamiResult struct {
|
||||
IdentitySource string `json:"identitySource"`
|
||||
Available bool `json:"available"`
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
OpenID string `json:"openId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// delegatedUser is the user a user-identity acts on behalf of.
|
||||
type delegatedUser struct {
|
||||
UserName string `json:"userName,omitempty"`
|
||||
OpenID string `json:"openId,omitempty"`
|
||||
}
|
||||
|
||||
// Options holds inputs for the whoami command.
|
||||
type Options struct {
|
||||
Factory *cmdutil.Factory
|
||||
As string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdWhoami creates the top-level whoami command. It reports the identity
|
||||
// that the next API call would actually use (resolved via Factory.ResolveAs),
|
||||
// together with the active profile, app, and token status. It is local-only:
|
||||
// no network calls are made.
|
||||
// together with the active profile, app, and token status. Output is always
|
||||
// JSON — whoami is consumed by agents. With the built-in credential path it is
|
||||
// local-only; when an external credential provider manages tokens, resolving
|
||||
// the identity may contact that provider.
|
||||
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current effective identity, app, profile, and token status",
|
||||
Short: "Show the current effective identity, app, profile, and token status (JSON)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return whoamiRun(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
// Output is always JSON. Accept (and ignore) --json so existing
|
||||
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
|
||||
// mode exists.
|
||||
cmd.Flags().Bool("json", true, "deprecated: output is always JSON")
|
||||
_ = cmd.Flags().MarkHidden("json")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
@@ -67,10 +82,11 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
ctx := cmd.Context()
|
||||
flagAs := core.Identity(opts.As)
|
||||
as := f.ResolveAs(ctx, cmd, flagAs)
|
||||
// Reject an explicit --as that does not resolve to a usable identity, so a
|
||||
// typo like `--as admin` fails clearly instead of echoing back a bogus
|
||||
// identity. Keeps the §5.1 invariant (identity is always user or bot) and
|
||||
// matches how api/service/shortcut commands validate the resolved identity.
|
||||
// Validate as a real API call does (strict mode, then identity) so whoami
|
||||
// can't preview an identity the next call would refuse.
|
||||
if err := f.CheckStrictMode(ctx, as); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -82,11 +98,7 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
formatPretty(f.IOStreams.Out, res)
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,17 +106,18 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
|
||||
// auto-detected result means auto-detect; otherwise a strict-mode forced
|
||||
// identity means strict-mode; otherwise it came from configured default-as.
|
||||
// Values are snake_case to match the other enum fields (e.g. tokenStatus).
|
||||
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
|
||||
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
|
||||
return "flag"
|
||||
}
|
||||
if autoDetected {
|
||||
return "auto-detect"
|
||||
return "auto_detect"
|
||||
}
|
||||
if strictForced != "" {
|
||||
return "strict-mode"
|
||||
return "strict_mode"
|
||||
}
|
||||
return "default-as"
|
||||
return "default_as"
|
||||
}
|
||||
|
||||
// buildResult maps the resolved identity and local diagnostics into the output.
|
||||
@@ -122,46 +135,29 @@ func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag iden
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
}
|
||||
// Use the diagnosed hint as-is: it is tailored to the credential source, so
|
||||
// it never says "auth login" when that is blocked under an external provider.
|
||||
switch as {
|
||||
case core.AsBot:
|
||||
res.Available = diag.Bot.Available
|
||||
res.TokenStatus = diag.Bot.Status
|
||||
if !diag.Bot.Available {
|
||||
res.Hint = "Bot identity not configured. Set app secret or bot token (see `lark-cli config --help`)."
|
||||
res.Hint = diag.Bot.Hint
|
||||
}
|
||||
default: // user
|
||||
res.Available = diag.User.Available
|
||||
res.OpenID = diag.User.OpenID
|
||||
res.UserName = diag.User.UserName
|
||||
res.TokenStatus = diag.User.TokenStatus
|
||||
if res.TokenStatus == "" {
|
||||
res.TokenStatus = "missing"
|
||||
// Use Status (not the raw TokenStatus) so the vocab matches the bot
|
||||
// branch: "ready" means usable for both. available stays the canonical
|
||||
// usable signal; tokenStatus is the readable state behind it.
|
||||
res.TokenStatus = diag.User.Status
|
||||
// Set onBehalfOf only when a user is actually resolved; an unresolved
|
||||
// user identity (not signed in) has no one to act on behalf of yet.
|
||||
if diag.User.UserName != "" || diag.User.OpenID != "" {
|
||||
res.OnBehalfOf = &delegatedUser{UserName: diag.User.UserName, OpenID: diag.User.OpenID}
|
||||
}
|
||||
if !diag.User.Available {
|
||||
res.Hint = "No usable user token. Run `lark-cli auth login`."
|
||||
res.Hint = diag.User.Hint
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// formatPretty writes the human-readable one-glance summary.
|
||||
func formatPretty(w io.Writer, r *whoamiResult) {
|
||||
fmt.Fprintf(w, "Profile: %s (%s, %s)\n", r.Profile, r.AppID, r.Brand)
|
||||
fmt.Fprintf(w, "Identity: %s (%s)\n", r.Identity, r.IdentitySource)
|
||||
if r.Identity == string(core.AsUser) && r.UserName != "" {
|
||||
if r.OpenID != "" {
|
||||
fmt.Fprintf(w, "User: %s (%s)\n", r.UserName, r.OpenID)
|
||||
} else {
|
||||
fmt.Fprintf(w, "User: %s\n", r.UserName)
|
||||
}
|
||||
}
|
||||
token := r.TokenStatus
|
||||
if !r.Available && r.Hint != "" {
|
||||
token = r.TokenStatus + " — " + r.Hint
|
||||
}
|
||||
// Write the label and value as separate %s args rather than one combined
|
||||
// literal. A single label-colon-value literal trips the public-content
|
||||
// credential scanner as a false-positive credential assignment; splitting
|
||||
// the args avoids it while producing identical output.
|
||||
fmt.Fprintf(w, "%s%s\n", "Token: ", token)
|
||||
}
|
||||
|
||||
@@ -5,15 +5,19 @@ package whoami
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
@@ -28,10 +32,10 @@ func TestResolveSource(t *testing.T) {
|
||||
}{
|
||||
{"explicit flag user", true, core.AsUser, false, "", "flag"},
|
||||
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
|
||||
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto-detect"},
|
||||
{"auto detected", false, "", true, "", "auto-detect"},
|
||||
{"strict mode", false, "", false, core.AsBot, "strict-mode"},
|
||||
{"default-as", false, "", false, "", "default-as"},
|
||||
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"},
|
||||
{"auto detected", false, "", true, "", "auto_detect"},
|
||||
{"strict mode", false, "", false, core.AsBot, "strict_mode"},
|
||||
{"default_as", false, "", false, "", "default_as"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -46,18 +50,19 @@ func TestResolveSource(t *testing.T) {
|
||||
func TestBuildResult_UserValid(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
|
||||
if r.Identity != "user" || r.IdentitySource != "auto-detect" {
|
||||
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
if !r.Available || r.TokenStatus != "valid" {
|
||||
// tokenStatus mirrors the unified Status vocab ("ready"), not the raw "valid".
|
||||
if !r.Available || r.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OpenID != "ou_x" || r.UserName != "Alice" {
|
||||
t.Fatalf("openId/userName = %q/%q", r.OpenID, r.UserName)
|
||||
if r.OnBehalfOf == nil || r.OnBehalfOf.OpenID != "ou_x" || r.OnBehalfOf.UserName != "Alice" {
|
||||
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x", r.OnBehalfOf)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
@@ -70,9 +75,9 @@ func TestBuildResult_UserValid(t *testing.T) {
|
||||
func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: false, TokenStatus: ""}, // never logged in
|
||||
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -80,8 +85,10 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||
if r.TokenStatus != "missing" {
|
||||
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint empty, want guidance")
|
||||
// whoami renders the diagnosed hint verbatim (single source of truth) so it
|
||||
// stays correct for the external-provider path without whoami knowing about it.
|
||||
if r.Hint != diag.User.Hint {
|
||||
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.User.Hint)
|
||||
}
|
||||
if r.DefaultAs != "auto" {
|
||||
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
|
||||
@@ -93,16 +100,16 @@ func TestBuildResult_BotReady(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "default-as", diag)
|
||||
r := buildResult(cfg, core.AsBot, "default_as", diag)
|
||||
|
||||
if r.Identity != "bot" || r.IdentitySource != "default-as" {
|
||||
if r.Identity != "bot" || r.IdentitySource != "default_as" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
if !r.Available || r.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OpenID != "" || r.UserName != "" {
|
||||
t.Fatalf("bot must not carry openId/userName: %#v", r)
|
||||
if r.OnBehalfOf != nil {
|
||||
t.Fatalf("bot must not carry onBehalfOf: %#v", r.OnBehalfOf)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
@@ -112,9 +119,9 @@ func TestBuildResult_BotReady(t *testing.T) {
|
||||
func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: false, Status: "not_configured"},
|
||||
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "auto-detect", diag)
|
||||
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -122,58 +129,8 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||
if r.TokenStatus != "not_configured" {
|
||||
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint empty, want guidance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_User(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "my-app", AppID: "cli_x", Brand: core.BrandLark,
|
||||
Identity: "user", IdentitySource: "auto-detect",
|
||||
Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice",
|
||||
})
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"Profile: my-app (cli_x, lark)",
|
||||
"Identity: user (auto-detect)",
|
||||
"User: Alice (ou_x)",
|
||||
"Token: valid",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\n--- got ---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_BotNoUserLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||
Identity: "bot", IdentitySource: "default-as",
|
||||
Available: true, TokenStatus: "ready",
|
||||
})
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "User:") {
|
||||
t.Errorf("bot output must not contain User: line\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Identity: bot (default-as)") || !strings.Contains(out, "Token: ready") {
|
||||
t.Errorf("unexpected bot output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_UnavailableShowsHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "p", AppID: "cli_x", Brand: core.BrandLark,
|
||||
Identity: "user", IdentitySource: "auto-detect",
|
||||
Available: false, TokenStatus: "missing",
|
||||
Hint: "No usable user token. Run `lark-cli auth login`.",
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Token: missing — No usable user token.") {
|
||||
t.Errorf("expected token line with hint, got:\n%s", out)
|
||||
if r.Hint != diag.Bot.Hint {
|
||||
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +140,7 @@ func TestWhoami_BotJSON(t *testing.T) {
|
||||
})
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
@@ -204,8 +161,8 @@ func TestWhoami_BotJSON(t *testing.T) {
|
||||
if got.IdentitySource == "" {
|
||||
t.Fatalf("identitySource empty")
|
||||
}
|
||||
if got.OpenID != "" {
|
||||
t.Fatalf("bot must not carry openId: %q", got.OpenID)
|
||||
if got.OnBehalfOf != nil {
|
||||
t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,3 +213,108 @@ func TestWhoami_ConfigErrorPropagates(t *testing.T) {
|
||||
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) {
|
||||
// Bot-only account → strict mode bot. A real `--as user` call would be
|
||||
// rejected by CheckStrictMode; whoami must reject it identically rather than
|
||||
// previewing a user identity the next call would refuse.
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
SupportedIdentities: 2, // bot only
|
||||
})
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", "user", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() with --as user under strict bot = nil, want strict-mode rejection")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeExtProvider struct {
|
||||
name string
|
||||
account *extcred.Account
|
||||
}
|
||||
|
||||
func (p *fakeExtProvider) Name() string { return p.name }
|
||||
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return p.account, nil
|
||||
}
|
||||
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil // no UAT served locally; whoami runs with verify=false
|
||||
}
|
||||
|
||||
func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) {
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) { return nil, nil },
|
||||
)
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
return f, out
|
||||
}
|
||||
|
||||
// Regression for the external-provider blind spot: with credentials managed by
|
||||
// an extension provider, a signed-in user must read as available, and an
|
||||
// unavailable identity must not be told to "auth login" (which is blocked).
|
||||
func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||
SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice",
|
||||
}
|
||||
f, out := externalWhoamiFactory(cfg)
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", "user", "--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
|
||||
}
|
||||
if got.Identity != "user" || !got.Available || got.TokenStatus != "ready" {
|
||||
t.Fatalf("got %#v, want user/available/ready", got)
|
||||
}
|
||||
if got.OnBehalfOf == nil || got.OnBehalfOf.UserName != "Alice" || got.OnBehalfOf.OpenID != "ou_x" {
|
||||
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x (delegated)", got.OnBehalfOf)
|
||||
}
|
||||
if got.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty when available", got.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||
SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in
|
||||
}
|
||||
f, out := externalWhoamiFactory(cfg)
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", "user", "--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
|
||||
}
|
||||
if got.Identity != "user" || got.Available {
|
||||
t.Fatalf("got identity=%q available=%v, want user/false", got.Identity, got.Available)
|
||||
}
|
||||
if strings.Contains(got.Hint, "auth login") {
|
||||
t.Fatalf("hint must not point at auth login under external provider: %q", got.Hint)
|
||||
}
|
||||
if !strings.Contains(got.Hint, "external") {
|
||||
t.Fatalf("hint should explain external management: %q", got.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`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
|
||||
|
||||
| Category | When | Exit | Typed struct |
|
||||
|
||||
@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
|
||||
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
||||
WithMissingScopes("mail:user_mailbox.message:send").
|
||||
WithIdentity("user").
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
|
||||
|
||||
if got.Category != errs.CategoryAuthorization {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
||||
WithHint("run lark-cli auth login --scope calendar:event:create").
|
||||
WithMissingScopes("calendar:event:create").
|
||||
WithIdentity("user").
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
|
||||
|
||||
buf, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
||||
"log_id": "20260520-0a1b2c3d",
|
||||
"identity": "user",
|
||||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
|
||||
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
|
||||
"missing_scopes": []any{"calendar:event:create"},
|
||||
}
|
||||
for k, want := range wantFields {
|
||||
|
||||
2
go.mod
2
go.mod
@@ -10,7 +10,7 @@ require (
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
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/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
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.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.2 h1:SCIcXHRmtpQbiaZgDTDi1NYNCzrusi7ePJBR9uKoduE=
|
||||
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/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
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.
|
||||
func commandFormResolver(service string) func(string) string {
|
||||
byForm := map[string]string{}
|
||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
||||
if svc.Name != service {
|
||||
continue
|
||||
}
|
||||
if svc, ok := registry.SchemaCatalog().Service(service); ok {
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||
}
|
||||
break
|
||||
}
|
||||
return func(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if id, ok := byForm[h]; ok {
|
||||
if id, ok := byForm[strings.TrimSpace(h)]; ok {
|
||||
return id
|
||||
}
|
||||
return strings.ReplaceAll(h, " ", ".")
|
||||
return headingToKey(h) // one home for the shortcut/method key convention
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// ### Tips -> tips
|
||||
// ### Examples -> examples: **description** + a ```fenced``` command
|
||||
// ### Skills -> skills: bullet skill names, added to the domain default
|
||||
// ### <other> -> extensions[] (custom section, flows through verbatim)
|
||||
// [[cmd]] -> a command reference, rendered as `cmd`
|
||||
//
|
||||
@@ -34,16 +35,56 @@ var standardSection = map[string]string{
|
||||
"Prerequisites": "prerequisites",
|
||||
"Tips": "tips",
|
||||
"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`") }
|
||||
|
||||
// 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
|
||||
// ("instances.get"). The space→dot rule holds where the command form matches
|
||||
// the method id; domains whose resource names differ (e.g. plural "messages"
|
||||
// vs id segment "message") need the registry's authoritative resource↔id table.
|
||||
func headingToKey(h string) string {
|
||||
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
|
||||
h = strings.TrimSpace(h)
|
||||
if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim
|
||||
return h
|
||||
}
|
||||
return strings.ReplaceAll(h, " ", ".")
|
||||
}
|
||||
|
||||
type mdSection struct {
|
||||
@@ -82,6 +123,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
if len(useWhen) > 0 {
|
||||
a.UseWhen = useWhen
|
||||
}
|
||||
var perCmdSkills []string
|
||||
for _, s := range secs {
|
||||
switch standardSection[s.label] {
|
||||
case "avoid_when":
|
||||
@@ -92,12 +134,14 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
a.Tips = s.items
|
||||
case "examples":
|
||||
a.Examples = s.cases
|
||||
case "skills":
|
||||
perCmdSkills = s.items
|
||||
default:
|
||||
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
|
||||
}
|
||||
}
|
||||
if skill != "" {
|
||||
a.Skills = []string{skill}
|
||||
if s := mergeSkills(skill, perCmdSkills); len(s) > 0 {
|
||||
a.Skills = s
|
||||
}
|
||||
out[curKey] = a
|
||||
}
|
||||
@@ -157,7 +201,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
inFence, fence = true, nil
|
||||
} else {
|
||||
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 = ""
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// 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
|
||||
// cmdutil annotations (risk_level, supportedIdentities) and adds the
|
||||
// "domain" axis that the hook selector and Rule path globs need.
|
||||
// policy engine, the hook selector, and help rendering consume. It wraps the
|
||||
// existing cmdutil annotations (risk_level, supportedIdentities) and adds the
|
||||
// "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:
|
||||
//
|
||||
@@ -51,6 +53,12 @@ const (
|
||||
|
||||
sourceAnnotationKey = "cmdmeta.source"
|
||||
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
|
||||
@@ -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
|
||||
// when no ancestor has the annotation -- this is the "unknown" state the
|
||||
// policy engine must treat as ALLOW.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -128,7 +129,7 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
|
||||
WithParam("--file").
|
||||
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.
|
||||
|
||||
@@ -18,6 +18,9 @@ type IOStreams struct {
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
IsTerminal bool
|
||||
// OutIsTerminal reports whether Out is an interactive terminal. Mirrors
|
||||
// IsTerminal; computed once in NewIOStreams and assignable directly in tests.
|
||||
OutIsTerminal bool
|
||||
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
|
||||
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
|
||||
// so they stay out of non-interactive output (pipes, CI, agent runs).
|
||||
@@ -27,19 +30,24 @@ type IOStreams struct {
|
||||
}
|
||||
|
||||
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
|
||||
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
|
||||
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
|
||||
// false.
|
||||
// IsTerminal / OutIsTerminal / StderrIsTerminal are each derived from the
|
||||
// underlying *os.File of in / out / errOut respectively; non-file
|
||||
// readers/writers (bytes.Buffer, strings.Reader, …) yield false.
|
||||
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
|
||||
isTerminal := false
|
||||
if f, ok := in.(*os.File); ok {
|
||||
isTerminal = term.IsTerminal(int(f.Fd()))
|
||||
fileIsTerminal := func(v any) bool {
|
||||
if f, ok := v.(*os.File); ok {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return false
|
||||
}
|
||||
stderrIsTerminal := false
|
||||
if f, ok := errOut.(*os.File); ok {
|
||||
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
|
||||
return &IOStreams{
|
||||
In: in,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
IsTerminal: fileIsTerminal(in),
|
||||
OutIsTerminal: fileIsTerminal(out),
|
||||
StderrIsTerminal: fileIsTerminal(errOut),
|
||||
}
|
||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
|
||||
}
|
||||
|
||||
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
||||
|
||||
31
internal/cmdutil/iostreams_test.go
Normal file
31
internal/cmdutil/iostreams_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewIOStreamsTerminalFlagsNonFile(t *testing.T) {
|
||||
s := NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
if s.IsTerminal || s.OutIsTerminal || s.StderrIsTerminal {
|
||||
t.Errorf("non-file streams must not be terminals: in=%v out=%v err=%v",
|
||||
s.IsTerminal, s.OutIsTerminal, s.StderrIsTerminal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIOStreamsTerminalFlagsPipe(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
s := NewIOStreams(r, w, w)
|
||||
if s.OutIsTerminal || s.StderrIsTerminal {
|
||||
t.Errorf("os.Pipe must not be a terminal: out=%v err=%v", s.OutIsTerminal, s.StderrIsTerminal)
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,10 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -40,8 +38,6 @@ const (
|
||||
BuildKindUnknown = "unknown"
|
||||
|
||||
officialModulePath = "github.com/larksuite/cli"
|
||||
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||
@@ -49,25 +45,6 @@ func UserAgentValue() string {
|
||||
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.
|
||||
func BaseSecurityHeaders() http.Header {
|
||||
h := make(http.Header)
|
||||
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
return h
|
||||
|
||||
@@ -6,7 +6,6 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
// Content safety scanning mode
|
||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||
|
||||
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
||||
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
||||
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
||||
// depend on internal/core (which would create an import cycle).
|
||||
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
|
||||
// Brand through core.ParseBrand, so callers can pass a raw brand string without
|
||||
// coupling this contract to core's brand enum.
|
||||
type ClassifyContext struct {
|
||||
Brand string // "feishu" | "lark" — drives console_url host
|
||||
AppID string // placed in console_url
|
||||
@@ -444,28 +446,27 @@ func extractMissingScopes(resp map[string]any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
||||
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
||||
// scopes list returns the bare /auth landing page; scopes are joined with
|
||||
// commas in the `q` query parameter so the console can pre-select them.
|
||||
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
|
||||
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
|
||||
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
|
||||
// returns the page carrying only clientID; otherwise scopes are joined with
|
||||
// commas in the `scopes` query parameter so the console can pre-select them.
|
||||
//
|
||||
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||
if appID == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == "lark" {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
||||
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
||||
// content must not be able to inject extra query parameters via `&`/`#`.
|
||||
pathID := url.PathEscape(appID)
|
||||
// QueryEscape both values — clientID and scopes both sit in the query
|
||||
// string, and untrusted content must not be able to inject extra query
|
||||
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
|
||||
// open-platform base URL stays a single source of truth.
|
||||
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
|
||||
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
|
||||
if len(scopes) == 0 {
|
||||
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
|
||||
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
|
||||
}
|
||||
|
||||
func intFromAny(v any) int {
|
||||
|
||||
@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
|
||||
name: "ampersand in scope smuggles extra param",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope&evil=injected"},
|
||||
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"q=scope&evil=injected"},
|
||||
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"scopes=scope&evil=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in scope splits fragment",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope#fragment"},
|
||||
wantInURL: []string{"q=scope%23fragment"},
|
||||
denyInURL: []string{"q=scope#fragment"},
|
||||
wantInURL: []string{"scopes=scope%23fragment"},
|
||||
denyInURL: []string{"scopes=scope#fragment"},
|
||||
},
|
||||
{
|
||||
name: "question mark in appID prematurely opens query",
|
||||
appID: "good?q=injected",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
||||
denyInURL: []string{"/app/good?q=injected/auth"},
|
||||
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
|
||||
denyInURL: []string{"clientID=good?q=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in appID truncates URL",
|
||||
appID: "good#fragment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%23fragment/auth"},
|
||||
denyInURL: []string{"/app/good#fragment/auth"},
|
||||
wantInURL: []string{"clientID=good%23fragment"},
|
||||
denyInURL: []string{"clientID=good#fragment"},
|
||||
},
|
||||
{
|
||||
name: "slash in appID escapes path segment",
|
||||
name: "slash in appID does not open a new path segment",
|
||||
appID: "good/extra/segment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
|
||||
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
|
||||
if pe.MissingScopes != nil {
|
||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||
}
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
||||
// at the app level — re-authenticating cannot fix it. The hint must
|
||||
// point to the developer console regardless of caller identity, or
|
||||
// agents will loop on `auth login` forever.
|
||||
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
|
||||
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
|
||||
for _, identity := range []string{"user", "bot", ""} {
|
||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
||||
if !strings.Contains(got, "developer console") {
|
||||
|
||||
@@ -10,8 +10,22 @@ import "github.com/larksuite/cli/errs"
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -27,6 +27,13 @@ func TestLookupCodeMeta_DriveCodes(t *testing.T) {
|
||||
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
|
||||
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
|
||||
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
// Secure label endpoint codes observed from drive +secure-label-update
|
||||
// failure telemetry.
|
||||
{1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false},
|
||||
{99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
|
||||
@@ -102,6 +102,62 @@ func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{1061001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
{1061002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{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 {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||
}
|
||||
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_Unknown(t *testing.T) {
|
||||
_, ok := LookupCodeMeta(999999)
|
||||
if ok {
|
||||
|
||||
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") }
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -61,12 +62,131 @@ func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, veri
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
// An external provider mints tokens on demand and blocks interactive auth,
|
||||
// so the built-in keychain heuristics and "auth login" hints don't apply.
|
||||
if provider := activeExternalProvider(ctx, f); provider != "" {
|
||||
return diagnoseExternal(ctx, f, cfg, provider, verify)
|
||||
}
|
||||
return Result{
|
||||
Bot: diagnoseBot(ctx, f, cfg, verify),
|
||||
User: diagnoseUser(ctx, f, cfg, verify),
|
||||
}
|
||||
}
|
||||
|
||||
// activeExternalProvider returns the active extension provider name, or "".
|
||||
// An error degrades to the built-in path: an unreachable provider would already
|
||||
// have failed the f.Config() that produced cfg.
|
||||
func activeExternalProvider(ctx context.Context, f *cmdutil.Factory) string {
|
||||
if f == nil || f.Credential == nil {
|
||||
return ""
|
||||
}
|
||||
name, err := f.Credential.ActiveExtensionProviderName(ctx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, verify bool) Result {
|
||||
if cfg == nil || cfg.AppID == "" {
|
||||
notConfigured := Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "not configured (missing app config)",
|
||||
Hint: externalCredentialHint(provider),
|
||||
}
|
||||
return Result{Bot: notConfigured, User: notConfigured}
|
||||
}
|
||||
// SupportedIdentities == 0 is "unspecified" — treat as both, per CanBot.
|
||||
ids := extcred.IdentitySupport(cfg.SupportedIdentities)
|
||||
supportsBot := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsBot)
|
||||
supportsUser := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsUser)
|
||||
return Result{
|
||||
Bot: diagnoseExternalBot(ctx, f, cfg, provider, supportsBot, verify),
|
||||
User: diagnoseExternalUser(ctx, f, cfg, provider, supportsUser, verify),
|
||||
}
|
||||
}
|
||||
|
||||
func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
|
||||
if !supported {
|
||||
return notProvidedExternally("Bot", provider)
|
||||
}
|
||||
id := Identity{Status: StatusReady, Available: true, Message: "Bot identity: ready (provided by " + provider + ")"}
|
||||
if !verify {
|
||||
return id
|
||||
}
|
||||
token, err := resolveBotToken(ctx, f, cfg)
|
||||
if err != nil {
|
||||
return externalVerifyFailed(id, "Bot", provider, err)
|
||||
}
|
||||
info, err := fetchBotInfo(ctx, f, cfg, token)
|
||||
if err != nil {
|
||||
return externalVerifyFailed(id, "Bot", provider, err)
|
||||
}
|
||||
id.Verified = boolPtr(true)
|
||||
id.OpenID = info.OpenID
|
||||
id.AppName = info.AppName
|
||||
return id
|
||||
}
|
||||
|
||||
func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
|
||||
if !supported {
|
||||
return notProvidedExternally("User", provider)
|
||||
}
|
||||
// enrichUserInfo populates UserOpenId only after the provider returns and
|
||||
// verifies a UAT (and clears it on failure), so a resolved open id is the
|
||||
// external analogue of a keychain token being present.
|
||||
if cfg.UserOpenId == "" {
|
||||
return Identity{
|
||||
Status: StatusMissing,
|
||||
Message: "User identity: not signed in via credential source " + provider,
|
||||
Hint: externalCredentialHint(provider),
|
||||
}
|
||||
}
|
||||
id := Identity{
|
||||
Status: StatusReady,
|
||||
Available: true,
|
||||
TokenStatus: StatusReady,
|
||||
UserName: cfg.UserName,
|
||||
OpenID: cfg.UserOpenId,
|
||||
Message: "User identity: ready (provided by " + provider + ")",
|
||||
}
|
||||
if !verify {
|
||||
return id
|
||||
}
|
||||
if _, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsUser, cfg.AppID)); err != nil {
|
||||
return externalVerifyFailed(id, "User", provider, err)
|
||||
}
|
||||
id.Verified = boolPtr(true)
|
||||
return id
|
||||
}
|
||||
|
||||
func notProvidedExternally(label, provider string) Identity {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: label + " identity: not provided by credential source " + provider,
|
||||
Hint: externalCredentialHint(provider),
|
||||
}
|
||||
}
|
||||
|
||||
// externalVerifyFailed flips id to verify-failed, keeping any identity fields
|
||||
// (open id, user name) already resolved before the probe.
|
||||
func externalVerifyFailed(id Identity, label, provider string, err error) Identity {
|
||||
id.Available = false
|
||||
id.Verified = boolPtr(false)
|
||||
id.Status = StatusVerifyFailed
|
||||
id.TokenStatus = ""
|
||||
id.Message = label + " identity: verify failed: " + err.Error()
|
||||
id.Hint = externalCredentialHint(provider)
|
||||
return id
|
||||
}
|
||||
|
||||
// externalCredentialHint reports the constraint, not a remediation: the
|
||||
// identity is the provider's to manage, not lark-cli's to fix. What to do about
|
||||
// it is the caller's call — there may be no user to ask.
|
||||
func externalCredentialHint(provider string) string {
|
||||
return fmt.Sprintf("managed by the external credential provider %q and cannot be configured via lark-cli", provider)
|
||||
}
|
||||
|
||||
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
||||
if cfg == nil || cfg.AppID == "" {
|
||||
return Identity{
|
||||
|
||||
@@ -10,9 +10,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
@@ -348,3 +350,136 @@ func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
|
||||
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeExtProvider is a minimal credential.extcred.Provider for exercising the
|
||||
// external-credential diagnosis path. account makes the provider "active";
|
||||
// token (when set) satisfies ResolveToken during verify.
|
||||
type fakeExtProvider struct {
|
||||
name string
|
||||
account *extcred.Account
|
||||
token *extcred.Token
|
||||
}
|
||||
|
||||
func (p *fakeExtProvider) Name() string { return p.name }
|
||||
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return p.account, nil
|
||||
}
|
||||
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return p.token, nil
|
||||
}
|
||||
|
||||
func externalFactory(prov *fakeExtProvider, cfg *core.CliConfig) *cmdutil.Factory {
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{prov}, nil, nil,
|
||||
func() (*http.Client, error) { return nil, nil },
|
||||
)
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{},
|
||||
}
|
||||
}
|
||||
|
||||
// assertExternalHint locks the contract that an external-provider hint never
|
||||
// points at interactive commands blocked under an external provider.
|
||||
func assertExternalHint(t *testing.T, hint string) {
|
||||
t.Helper()
|
||||
if hint == "" {
|
||||
t.Fatalf("hint empty, want external guidance")
|
||||
}
|
||||
for _, blocked := range []string{"auth login", "config --help"} {
|
||||
if strings.Contains(hint, blocked) {
|
||||
t.Fatalf("hint %q must not point at %q (blocked under external provider)", hint, blocked)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(hint, "external") {
|
||||
t.Fatalf("hint %q should explain credentials are external", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_External_UserReady(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice"}
|
||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
// The bug this guards: the built-in path read the keychain (empty under an
|
||||
// external provider) and reported the user as missing. Now availability
|
||||
// follows the resolved account, so a signed-in user reads as ready.
|
||||
if !got.User.Available || got.User.Status != StatusReady || got.User.TokenStatus != StatusReady {
|
||||
t.Fatalf("user = %#v, want ready/available", got.User)
|
||||
}
|
||||
if got.User.OpenID != "ou_x" || got.User.UserName != "Alice" {
|
||||
t.Fatalf("user identity = %#v", got.User)
|
||||
}
|
||||
if got.User.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty when available", got.User.Hint)
|
||||
}
|
||||
if !got.Bot.Available || got.Bot.Status != StatusReady {
|
||||
t.Fatalf("bot = %#v, want ready/available", got.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_External_UserNotSignedIn(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll)}
|
||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.User.Available || got.User.Status != StatusMissing {
|
||||
t.Fatalf("user = %#v, want missing/unavailable", got.User)
|
||||
}
|
||||
assertExternalHint(t, got.User.Hint)
|
||||
}
|
||||
|
||||
func TestDiagnose_External_BotOnly(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsBot), UserOpenId: "ou_x"}
|
||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if !got.Bot.Available || got.Bot.Status != StatusReady {
|
||||
t.Fatalf("bot = %#v, want ready/available", got.Bot)
|
||||
}
|
||||
// Provider declares bot-only: user is unavailable even though an open id is
|
||||
// present, and the hint is external (not "auth login").
|
||||
if got.User.Available || got.User.Status != StatusNotConfigured {
|
||||
t.Fatalf("user = %#v, want not_configured/unavailable", got.User)
|
||||
}
|
||||
assertExternalHint(t, got.User.Hint)
|
||||
}
|
||||
|
||||
func TestDiagnose_External_UserOnly(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandLark, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Bob"}
|
||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if !got.User.Available || got.User.Status != StatusReady {
|
||||
t.Fatalf("user = %#v, want ready/available", got.User)
|
||||
}
|
||||
if got.Bot.Available || got.Bot.Status != StatusNotConfigured {
|
||||
t.Fatalf("bot = %#v, want not_configured/unavailable", got.Bot)
|
||||
}
|
||||
assertExternalHint(t, got.Bot.Hint)
|
||||
}
|
||||
|
||||
func TestDiagnose_External_VerifyUserResolvesToken(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Alice"}
|
||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}, token: &extcred.Token{Value: "ext-uat"}}, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if !got.User.Available || got.User.Verified == nil || !*got.User.Verified {
|
||||
t.Fatalf("user = %#v, want available and verified", got.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_External_VerifyUserTokenUnavailable(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x"}
|
||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.User.Available || got.User.Status != StatusVerifyFailed {
|
||||
t.Fatalf("user = %#v, want verify_failed/unavailable", got.User)
|
||||
}
|
||||
if got.User.Verified == nil || *got.User.Verified {
|
||||
t.Fatalf("verified = %v, want false", got.User.Verified)
|
||||
}
|
||||
assertExternalHint(t, got.User.Hint)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@ import "encoding/json"
|
||||
// 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
|
||||
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
|
||||
// Skills entries are skill names (or name/path) rendered as runnable
|
||||
// `lark-cli skills read <entry>` pointers.
|
||||
// Skills entries are either a bare skill name (e.g. "lark-doc") or a
|
||||
// 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 {
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||
|
||||
@@ -52,6 +52,9 @@ func isPlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(trimmed)
|
||||
if normalized == "" ||
|
||||
normalized == "=" ||
|
||||
printfPlaceholderValue(normalized) ||
|
||||
htmlEntityAnglePlaceholder(normalized) ||
|
||||
starMaskedPlaceholder(normalized) ||
|
||||
percentWrappedPlaceholder(normalized) ||
|
||||
angleWrappedPlaceholder(normalized) ||
|
||||
urlWithAnglePlaceholder(normalized) ||
|
||||
@@ -61,9 +64,28 @@ func isPlaceholderValue(value string) bool {
|
||||
return namedPlaceholderValue(normalized)
|
||||
}
|
||||
|
||||
func htmlEntityAnglePlaceholder(value string) bool {
|
||||
if !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
|
||||
return false
|
||||
}
|
||||
return anglePlaceholderIdentifier(strings.TrimSuffix(strings.TrimPrefix(value, "<"), ">"))
|
||||
}
|
||||
|
||||
func starMaskedPlaceholder(value string) bool {
|
||||
var stars int
|
||||
for _, r := range value {
|
||||
if r == '*' {
|
||||
stars++
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return stars >= 3
|
||||
}
|
||||
|
||||
func namedPlaceholderValue(value string) bool {
|
||||
switch value {
|
||||
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
|
||||
case "...", "***", "****", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret", "test-token", "dry-run", "dry_run":
|
||||
return true
|
||||
}
|
||||
return strings.Contains(value, "cli_example") ||
|
||||
@@ -71,6 +93,15 @@ func namedPlaceholderValue(value string) bool {
|
||||
conventionalNamedPlaceholderValue(value)
|
||||
}
|
||||
|
||||
func printfPlaceholderValue(value string) bool {
|
||||
switch value {
|
||||
case "%d", "%s", "%q", "%v", "%w", "%x", "%T":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func allXPlaceholder(value string) bool {
|
||||
if len(value) < 4 {
|
||||
return false
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -54,8 +55,9 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
||||
if value == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isBenignCodeCredentialExpression(file, value) ||
|
||||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
isPermissionScopeIdentifierAssignment(keyName, value) ||
|
||||
isResourceTokenPlaceholderAssignment(keyName, value) {
|
||||
continue
|
||||
}
|
||||
@@ -77,12 +79,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||
}
|
||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||
if isPlaceholderCredentialURL(match) {
|
||||
if isPlaceholderCredentialURL(file, match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
||||
}
|
||||
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
||||
if !warnForPrivateIPv4(file) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
||||
}
|
||||
if source == "branch" && automationBranchRE.MatchString(line) {
|
||||
@@ -129,6 +134,9 @@ func isCredentialAssignmentMatch(match string) bool {
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
}
|
||||
|
||||
@@ -266,7 +274,7 @@ func isResourceTokenPlaceholderAssignment(key, value string) bool {
|
||||
case key == "retry_without_token" && numericStringPlaceholderValue(value):
|
||||
return true
|
||||
case tokenLikePlaceholderKey(key):
|
||||
return tokenLikePlaceholderValue(value)
|
||||
return tokenLikePlaceholderValue(key, value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -278,12 +286,16 @@ func tokenLikePlaceholderKey(key string) bool {
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func tokenLikePlaceholderValue(value string) bool {
|
||||
func tokenLikePlaceholderValue(key, value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
if authCredentialTokenKey(key) {
|
||||
return false
|
||||
}
|
||||
return resourceTokenPlaceholderValue(value) ||
|
||||
maskedTokenFixturePlaceholderValue(key, normalized) ||
|
||||
isPlaceholderValue(value) ||
|
||||
normalized == "token" ||
|
||||
strings.Contains(normalized, "...") ||
|
||||
@@ -293,6 +305,149 @@ func tokenLikePlaceholderValue(value string) bool {
|
||||
strings.HasPrefix(normalized, ".")
|
||||
}
|
||||
|
||||
func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
||||
if authCredentialTokenKey(key) {
|
||||
return false
|
||||
}
|
||||
var stars, alnum int
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r == '*':
|
||||
stars++
|
||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||
alnum++
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return stars >= 6 && alnum > 0
|
||||
}
|
||||
|
||||
func isWeakTokenCredentialKey(key string) bool {
|
||||
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
|
||||
return false
|
||||
}
|
||||
return key == "token" ||
|
||||
strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func isStrongTokenCredentialKey(key string) bool {
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
for _, phrase := range [][2]string{
|
||||
{"access", "token"},
|
||||
{"refresh", "token"},
|
||||
{"auth", "token"},
|
||||
{"bearer", "token"},
|
||||
{"session", "token"},
|
||||
{"service", "token"},
|
||||
{"bot", "token"},
|
||||
{"api", "token"},
|
||||
{"secret", "token"},
|
||||
} {
|
||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func weakTokenValueLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
if normalized == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isPlaceholderValue(value) {
|
||||
return false
|
||||
}
|
||||
candidate := unwrapCredentialValue(normalized)
|
||||
return credentialShapedIdentifier(candidate) ||
|
||||
highEntropyCredentialValue(candidate) ||
|
||||
commandSubstitutionLooksCredentialLike(normalized) ||
|
||||
(strings.Contains(normalized, "://") &&
|
||||
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
|
||||
}
|
||||
|
||||
func unwrapCredentialValue(value string) string {
|
||||
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
|
||||
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
|
||||
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||
}
|
||||
value = strings.TrimPrefix(value, "$")
|
||||
value = strings.Trim(value, "%")
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func highEntropyCredentialValue(value string) bool {
|
||||
if len(value) < 32 {
|
||||
return false
|
||||
}
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
hasLetter = true
|
||||
case r >= '0' && r <= '9':
|
||||
hasDigit = true
|
||||
case r == '_' || r == '-' || r == '.' || r == '=':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
|
||||
}
|
||||
|
||||
func shannonEntropy(value string) float64 {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
counts := map[rune]int{}
|
||||
for _, r := range value {
|
||||
counts[r]++
|
||||
}
|
||||
var entropy float64
|
||||
length := float64(len([]rune(value)))
|
||||
for _, count := range counts {
|
||||
p := float64(count) / length
|
||||
entropy -= p * log2(p)
|
||||
}
|
||||
return entropy
|
||||
}
|
||||
|
||||
func log2(value float64) float64 {
|
||||
return math.Log(value) / math.Ln2
|
||||
}
|
||||
|
||||
func authCredentialTokenKey(key string) bool {
|
||||
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
|
||||
case "access_token",
|
||||
"api_token",
|
||||
"bot_token",
|
||||
"refresh_token",
|
||||
"secret_token",
|
||||
"session_token",
|
||||
"service_token",
|
||||
"bearer_token",
|
||||
"auth_token",
|
||||
"authorization_token",
|
||||
"id_token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isPermissionScopeIdentifierAssignment(key, value string) bool {
|
||||
if !strings.HasSuffix(key, "_token") {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.Trim(value, `"',;`)) {
|
||||
case "read", "write", "modify", "readonly", "get_as_user":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func idempotencyTokenPlaceholderValue(value string) bool {
|
||||
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
|
||||
}
|
||||
@@ -333,20 +488,87 @@ func numericStringPlaceholderValue(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isBenignCodeCredentialExpression(file, value string) bool {
|
||||
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||
return true
|
||||
}
|
||||
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
|
||||
if !sourceCodeFile(file) || credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
|
||||
return isBenignTypedCredentialRHS(rhs)
|
||||
}
|
||||
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
|
||||
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
|
||||
return true
|
||||
}
|
||||
if sourceCodeFormatStringLiteral(normalized) && sourceCodeFormatArgumentContext(line, match) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(match, "+") {
|
||||
return true
|
||||
}
|
||||
if rawValueQuoted {
|
||||
return false
|
||||
}
|
||||
if quotedLiteral(value) {
|
||||
return sourceCodeLiteralLooksNonSecret(value, false)
|
||||
}
|
||||
return codeReferenceExpression(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
|
||||
idx := strings.Index(line, match)
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(line[idx+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
return "", false
|
||||
}
|
||||
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
|
||||
assignmentIdx := strings.Index(typeAndRHS, "=")
|
||||
if assignmentIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
|
||||
}
|
||||
|
||||
func isBenignTypedCredentialRHS(value string) bool {
|
||||
value = strings.TrimRight(strings.TrimSpace(value), ",;")
|
||||
if value == "" || isNonSecretLiteralValue(value) || isPlaceholderValue(value) {
|
||||
return true
|
||||
}
|
||||
if credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if sourceCodeLiteralLooksNonSecret(value, !quotedLiteral(value)) {
|
||||
return true
|
||||
}
|
||||
if quotedLiteral(value) {
|
||||
return false
|
||||
}
|
||||
return codeReferenceExpression(value)
|
||||
}
|
||||
|
||||
func credentialAssignmentRawValueQuoted(match string) bool {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(match[len(key):], ":"))
|
||||
rest = strings.TrimSpace(strings.TrimPrefix(rest, "="))
|
||||
return strings.HasPrefix(rest, `"`) || strings.HasPrefix(rest, `'`)
|
||||
}
|
||||
|
||||
func sourceCodeFile(file string) bool {
|
||||
switch filepath.Ext(file) {
|
||||
case ".go", ".py":
|
||||
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -360,7 +582,147 @@ func quotedLiteral(value string) bool {
|
||||
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
|
||||
}
|
||||
|
||||
func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
|
||||
literal := strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
if strings.HasPrefix(literal, "/") {
|
||||
return true
|
||||
}
|
||||
return (allowNumeric && numericStringPlaceholderValue(literal)) ||
|
||||
sourceCodeEnvVarNameLiteral(literal) ||
|
||||
sourceCodeAttributeNameLiteral(literal) ||
|
||||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
|
||||
sourceCodeCredentialTermLiteral(literal) ||
|
||||
sourceCodeCredentialPrefixLiteral(literal) ||
|
||||
sourceCodeVocabularyLiteral(literal) ||
|
||||
sourceCodeSchemaTypeLiteral(literal) ||
|
||||
benignCredentialStatusLiteral(literal)
|
||||
}
|
||||
|
||||
func sourceCodeFormatArgumentContext(line, match string) bool {
|
||||
idx := strings.Index(line, match)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
prefix := line[:idx]
|
||||
if semicolon := strings.LastIndex(prefix, ";"); semicolon >= 0 {
|
||||
prefix = prefix[semicolon+1:]
|
||||
}
|
||||
return strings.Contains(prefix, "fmt.") ||
|
||||
strings.Contains(prefix, "log.") ||
|
||||
strings.Contains(prefix, "printf(") ||
|
||||
strings.Contains(prefix, "Printf(") ||
|
||||
strings.Contains(prefix, "Errorf(") ||
|
||||
strings.Contains(prefix, "Fprintf(")
|
||||
}
|
||||
|
||||
func sourceCodeFormatStringLiteral(value string) bool {
|
||||
for i := 0; i < len(value)-1; i++ {
|
||||
if value[i] != '%' {
|
||||
continue
|
||||
}
|
||||
if value[i+1] == '%' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
j := i + 1
|
||||
for j < len(value) && strings.ContainsRune("#+- 0.0123456789", rune(value[j])) {
|
||||
j++
|
||||
}
|
||||
if j < len(value) && strings.ContainsRune("vTtbcdoOqxXUeEfFgGspw", rune(value[j])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sourceCodeEnvVarNameLiteral(value string) bool {
|
||||
if value == "" || !strings.Contains(value, "_") {
|
||||
return false
|
||||
}
|
||||
var hasCredentialMarker bool
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"} {
|
||||
if strings.Contains(value, marker) {
|
||||
hasCredentialMarker = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return hasCredentialMarker
|
||||
}
|
||||
|
||||
func sourceCodeAttributeNameLiteral(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
return strings.HasPrefix(normalized, "data-") && delimitedPlaceholderIdentifier(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeFakeOrPlaceholderLiteral(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
return strings.HasPrefix(normalized, "fake_") ||
|
||||
strings.HasPrefix(normalized, "fake-") ||
|
||||
strings.Contains(normalized, "placeholder") ||
|
||||
(strings.Contains(normalized, "<") && strings.Contains(normalized, ">"))
|
||||
}
|
||||
|
||||
func sourceCodeCredentialTermLiteral(value string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
|
||||
return conventionalCredentialPlaceholderName(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeCredentialPrefixLiteral(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "appsecret:":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCodeVocabularyLiteral(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "bot", "tenant", "user":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCodeSchemaTypeLiteral(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
return normalized == "string" || strings.HasPrefix(normalized, "string(")
|
||||
}
|
||||
|
||||
func benignCredentialStatusLiteral(value string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
|
||||
if !delimitedPlaceholderIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"bad_fmt",
|
||||
"expired",
|
||||
"format",
|
||||
"invalid",
|
||||
"missing",
|
||||
"permission",
|
||||
"status",
|
||||
"type",
|
||||
} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func codeReferenceExpression(value string) bool {
|
||||
value = strings.TrimRight(strings.TrimSpace(value), ";")
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
@@ -369,7 +731,10 @@ func codeReferenceExpression(value string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return codeIdentifier(value) && !credentialNameFragment(value)
|
||||
if !codeIdentifier(value) {
|
||||
return false
|
||||
}
|
||||
return codeIdentifier(value)
|
||||
}
|
||||
|
||||
func codeIdentifier(value string) bool {
|
||||
@@ -386,16 +751,6 @@ func codeIdentifier(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialNameFragment(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
@@ -597,7 +952,7 @@ func looksLikeEqualityComparison(value string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
||||
}
|
||||
|
||||
func isPlaceholderCredentialURL(raw string) bool {
|
||||
func isPlaceholderCredentialURL(file, raw string) bool {
|
||||
userInfo, ok := credentialURLUserInfo(raw)
|
||||
if !ok {
|
||||
return false
|
||||
@@ -606,7 +961,8 @@ func isPlaceholderCredentialURL(raw string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return credentialURLPasswordPlaceholder(password)
|
||||
return credentialURLPasswordPlaceholder(password) ||
|
||||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
|
||||
}
|
||||
|
||||
func credentialURLPasswordPlaceholder(password string) bool {
|
||||
@@ -620,6 +976,46 @@ func credentialURLPasswordPlaceholder(password string) bool {
|
||||
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
|
||||
}
|
||||
|
||||
func credentialURLPasswordFixture(password string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
||||
switch normalized {
|
||||
case "p",
|
||||
"pass",
|
||||
"password",
|
||||
"pat_abc",
|
||||
"pw",
|
||||
"s3cret",
|
||||
"secret",
|
||||
"t":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sourceOrTestFixtureFile(file string) bool {
|
||||
normalized := filepath.ToSlash(file)
|
||||
return sourceCodeFile(normalized) ||
|
||||
strings.HasPrefix(normalized, "testdata/") ||
|
||||
strings.HasPrefix(normalized, "fixtures/") ||
|
||||
strings.Contains(normalized, "/testdata/") ||
|
||||
strings.Contains(normalized, "/fixtures/")
|
||||
}
|
||||
|
||||
func warnForPrivateIPv4(file string) bool {
|
||||
normalized := filepath.ToSlash(file)
|
||||
if sourceOrTestFixtureFile(normalized) {
|
||||
return false
|
||||
}
|
||||
switch filepath.Ext(normalized) {
|
||||
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
|
||||
return true
|
||||
default:
|
||||
return strings.HasPrefix(normalized, "docs/") ||
|
||||
strings.HasPrefix(normalized, "skills/")
|
||||
}
|
||||
}
|
||||
|
||||
func credentialURLUserInfo(raw string) (string, bool) {
|
||||
schemeIdx := strings.Index(raw, "://")
|
||||
if schemeIdx < 0 {
|
||||
|
||||
@@ -61,6 +61,19 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
|
||||
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@10.0.0.1:3128"`,
|
||||
`target := "socks5://admin:secret@172.16.0.1:1080"`,
|
||||
`host := "192.168.0.10"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_private_ipv4" {
|
||||
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
|
||||
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
|
||||
if len(benign) != 0 {
|
||||
@@ -632,6 +645,45 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@proxy:8080"`,
|
||||
`repo := "https://u:t@h/r.git"`,
|
||||
`target := "https://attacker:pw@open.feishu.cn"`,
|
||||
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
|
||||
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_credential_url" {
|
||||
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
|
||||
`proxy: http://user:pass@proxy:8080`,
|
||||
`repo: https://u:t@h/r.git`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_credential_url" {
|
||||
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
|
||||
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
|
||||
`endpoint: http://10.0.0.1:8080`,
|
||||
`redis: 192.168.1.10:6379`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_private_ipv4" {
|
||||
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
|
||||
for _, item := range got {
|
||||
@@ -648,6 +700,7 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
||||
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
|
||||
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
|
||||
"URL=https://<user>:real-secret@example.invalid/path",
|
||||
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -661,8 +714,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
|
||||
if count != 4 {
|
||||
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,6 +777,68 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
|
||||
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
|
||||
`{"token":"img_abc123"}`,
|
||||
`{"token":"img_live_secret"}`,
|
||||
`{"token":"img_prod_key"}`,
|
||||
`token=ab********cd`,
|
||||
`{"image_token":"img_live_secret"}`,
|
||||
`{"data_mail_token":"mail_abc123"}`,
|
||||
`{"whiteboard_token":"board_v3_example"}`,
|
||||
`{"want_token":"token from callback"}`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
stripeToken := "sk_" + "live_1234567890abcdef"
|
||||
randomToken := strings.Join([]string{
|
||||
"a1b2c3d4",
|
||||
"e5f6g7h8",
|
||||
"i9j0k1l2",
|
||||
"m3n4p5q6",
|
||||
}, "")
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"token":"` + githubToken + `"}`,
|
||||
`token=` + stripeToken,
|
||||
`{"image_token":"` + githubToken + `"}`,
|
||||
`{"token":"` + randomToken + `"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"access_token":"img_abc123"}`,
|
||||
`{"api_token":"img_live_secret"}`,
|
||||
`{"service_token":"ab********cd"}`,
|
||||
`{"bot_token":"board_v3_example"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
for _, item := range got {
|
||||
@@ -770,6 +885,172 @@ func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPythonCredentialTypeAnnotations(t *testing.T) {
|
||||
got := ScanFile("fixtures/doc_word_stat.py", []byte(strings.Join([]string{
|
||||
"class Counter:",
|
||||
" def __init__(self) -> None:",
|
||||
" self._token_kind: TokenKind | None = None",
|
||||
" self.access_token: AccessToken | None = None",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("python credential-shaped type annotations should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
|
||||
got := ScanFile("fixtures/auth_paths.go", []byte(strings.Join([]string{
|
||||
`const PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token"`,
|
||||
`return fmt.Errorf("failed to remove token: %v", err)`,
|
||||
`const LarkErrTokenMissing = "token_missing"`,
|
||||
`const LarkErrTokenExpired = 99991677`,
|
||||
`const CliAppSecret = "LARKSUITE_CLI_APP_SECRET"`,
|
||||
`const LargeAttachmentTokenAttr = "data-mail-token"`,
|
||||
`const fakeOfficeTokenPrefix = "fake_office_"`,
|
||||
`fmt.Fprintf(w, " - token=%s filename=%s\n", att.Token, att.FileName)`,
|
||||
`tokenTypeHint := "access_token"`,
|
||||
`const TokenTenant Token = "tenant"`,
|
||||
`const secretKeyPrefix = "appsecret:"`,
|
||||
`output.PrintJson(out, map[string]interface{}{"appSecret": "****"})`,
|
||||
`return &credential.TokenResult{Token: "test-token"}, nil`,
|
||||
`fmt.Fprintf(w, "password=%s\n", pat)`,
|
||||
`text += "(img_token:" + imgToken + ")"`,
|
||||
`map[string]interface{}{"token": "string(optional, from inspect)"}`,
|
||||
`this.token = token;`,
|
||||
`// AppSecret: "appsecret:<appId>"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("source code non-secret literals should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`app_secret=***`,
|
||||
`{"token":"<wiki_token>"}`,
|
||||
`{"token":"Pgrrwvr***********UnRb"}`,
|
||||
`"scope_name": "auth:user_access_token:read"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("public placeholders and scope identifiers should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
|
||||
"client_secret=realprefix***realsuffix",
|
||||
"client_secret=ab********cd",
|
||||
"access_token=ab********cd",
|
||||
"refresh_token=realprefix********realsuffix",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/ci.yml", []byte(strings.Join([]string{
|
||||
"LARKSUITE_CLI_APP_SECRET=dry-run",
|
||||
"client_secret: dry_run",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("dry-run credential placeholders should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
text string
|
||||
}{
|
||||
{
|
||||
name: "typescript simple secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "typescript numeric password",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const password: string = "12345678901234567890"`,
|
||||
},
|
||||
{
|
||||
name: "typescript union secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python simple secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python union secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str | None = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python optional secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ScanFile(tc.file, []byte(tc.text+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("typed credential assignment should be reported: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
|
||||
`const ClientSecret = "real-client-secret-value"`,
|
||||
`const GithubToken = "` + githubToken + `"`,
|
||||
`const Password = "12345678901234567890"`,
|
||||
`const ClientSecretNumber = "12345678901234567890"`,
|
||||
`const ClientSecretFormat = "abc%sdefreal"`,
|
||||
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 6 {
|
||||
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPrintfCredentialPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
"client_secret=%s",
|
||||
"access_token=%v",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("printf placeholders should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/lark-doc-fetch.md", []byte(strings.Join([]string{
|
||||
`<img token="..." url="https://..." width="..." height="..."/>`,
|
||||
@@ -886,10 +1167,12 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
|
||||
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@ package registry
|
||||
import "github.com/larksuite/cli/internal/apicatalog"
|
||||
|
||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
||||
// and schema lint.
|
||||
// metadata — deterministic across machines, for golden tests and schema lint.
|
||||
func EmbeddedCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||
}
|
||||
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
|
||||
func RuntimeCatalog() apicatalog.Catalog {
|
||||
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/meta"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
//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)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// 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 {
|
||||
return MergedRegistry{
|
||||
Version: "test-1.0",
|
||||
Version: "1.0.0",
|
||||
Services: []meta.Service{
|
||||
{
|
||||
Name: name,
|
||||
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(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()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(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()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
||||
if data == nil {
|
||||
t.Fatal("expected non-nil data")
|
||||
}
|
||||
if reg.Version != "test-1.0" {
|
||||
t.Errorf("expected version test-1.0, got %s", reg.Version)
|
||||
if reg.Version != "1.0.0" {
|
||||
t.Errorf("expected version 1.0.0, got %s", reg.Version)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,13 +59,9 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
|
||||
if appID == "" || scope == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
host,
|
||||
"%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
core.ResolveOpenBaseURL(brand),
|
||||
url.QueryEscape(appID),
|
||||
url.QueryEscape(scope),
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ type InstallMethod int
|
||||
|
||||
const (
|
||||
InstallNpm InstallMethod = iota
|
||||
InstallPnpm
|
||||
InstallManual
|
||||
)
|
||||
|
||||
@@ -53,22 +54,32 @@ var (
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
type DetectResult struct {
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
PnpmAvailable bool
|
||||
}
|
||||
|
||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||
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.
|
||||
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"
|
||||
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.
|
||||
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
PnpmInstallOverride func(version string) *NpmResult
|
||||
SkillsIndexFetchOverride func() *NpmResult
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
@@ -101,17 +113,38 @@ type Updater struct {
|
||||
// running binary is successfully renamed to .old. Used by
|
||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||
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.
|
||||
func New() *Updater { return &Updater{} }
|
||||
|
||||
// DetectInstallMethod determines how the CLI was installed and whether
|
||||
// npm is available for auto-update.
|
||||
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||
// owning package manager is available for auto-update.
|
||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
if u.DetectOverride != nil {
|
||||
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()
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual}
|
||||
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
if err != nil {
|
||||
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
|
||||
if strings.Contains(resolved, "node_modules") {
|
||||
method = InstallNpm
|
||||
}
|
||||
|
||||
npmAvailable := false
|
||||
if method == InstallNpm {
|
||||
if _, err := exec.LookPath("npm"); err == nil {
|
||||
npmAvailable = true
|
||||
if containsPnpmMarker(resolved) {
|
||||
method = InstallPnpm
|
||||
} else {
|
||||
method = InstallNpm
|
||||
}
|
||||
}
|
||||
|
||||
return DetectResult{
|
||||
Method: method,
|
||||
ResolvedPath: resolved,
|
||||
NpmAvailable: npmAvailable,
|
||||
d := DetectResult{Method: method, ResolvedPath: resolved}
|
||||
switch method {
|
||||
case InstallNpm:
|
||||
d.NpmAvailable = npmOnPath
|
||||
case InstallPnpm:
|
||||
d.PnpmAvailable = pnpmOnPath
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// containsPnpmMarker reports whether the resolved binary path belongs to a
|
||||
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
|
||||
// (a ".pnpm" directory segment) and the global content-addressable store,
|
||||
// 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>.
|
||||
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
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 {
|
||||
if u.SkillsIndexFetchOverride != nil {
|
||||
return u.SkillsIndexFetchOverride()
|
||||
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
|
||||
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 {
|
||||
if u.SkillsCommandOverride != nil {
|
||||
return u.SkillsCommandOverride(args...)
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
||||
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
|
||||
@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
const (
|
||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||
cacheTTL = 24 * time.Hour
|
||||
fetchTimeout = 5 * time.Second
|
||||
fetchTimeout = 15 * time.Second
|
||||
stateFile = "update-state.json"
|
||||
maxBody = 256 << 10 // 256 KB
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.60",
|
||||
"version": "1.0.67",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
||||
exit 1
|
||||
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" ||
|
||||
! grep -Fq "contents: read" <<<"$section" ||
|
||||
! 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
|
||||
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
|
||||
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
||||
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 or domain mode is skip"
|
||||
exit 1
|
||||
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 path = require("node:path");
|
||||
const { labelDomainsForPath } = require("../domain-map");
|
||||
|
||||
// ============================================================================
|
||||
// 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 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 CLASS_STANDARDS = {
|
||||
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
|
||||
|
||||
// Get business domain label based on CODEOWNERS path mapping
|
||||
function getBusinessDomain(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
return labelDomainsForPath(filePath)[0] || "";
|
||||
}
|
||||
|
||||
async function detectNewShortcutDomain(files) {
|
||||
|
||||
@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
script="$repo_root/scripts/resolve-changed-from.sh"
|
||||
|
||||
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"
|
||||
|
||||
git_init() {
|
||||
|
||||
@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
|
||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{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 {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -145,7 +145,10 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
||||
existing := map[string]bool{}
|
||||
token := ""
|
||||
for {
|
||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
||||
params := map[string]interface{}{"page_size": 100}
|
||||
if env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
if token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
@@ -168,7 +171,11 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
||||
|
||||
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -208,11 +215,10 @@ func auditListTables(rctx *common.RuntimeContext) []string {
|
||||
|
||||
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
||||
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"tables": strings.Join(tables, ","),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
|
||||
@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
{Name: "app-id", Desc: "Miaoda app id", 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"},
|
||||
}, 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 {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
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")})
|
||||
},
|
||||
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)
|
||||
defer stop()
|
||||
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})
|
||||
stop()
|
||||
if err != nil {
|
||||
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", 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 {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
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})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
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})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbAuditSetHint)
|
||||
|
||||
@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{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 {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
||||
|
||||
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
||||
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 != "" {
|
||||
params["table"] = t
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
|
||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{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 {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -77,10 +77,9 @@ var AppsDBChangelogList = common.Shortcut{
|
||||
|
||||
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
||||
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
|
||||
@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
|
||||
{Name: "table", Desc: "source table", Required: true},
|
||||
{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)"},
|
||||
}, 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 {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDataExportPath(appID)).
|
||||
Desc("Export Miaoda app table data (raw bytes)").
|
||||
Params(map[string]interface{}{
|
||||
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{
|
||||
"table": strings.TrimSpace(rctx.Str("table")),
|
||||
"format": format, "limit": rctx.Int("limit"),
|
||||
})
|
||||
}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
@@ -95,15 +95,18 @@ var AppsDBDataExport = common.Shortcut{
|
||||
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
||||
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
||||
|
||||
exportQuery := larkcore.QueryParams{
|
||||
"table": []string{table},
|
||||
"format": []string{format},
|
||||
"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: larkcore.QueryParams{
|
||||
"env": []string{dbEnv(rctx)},
|
||||
"table": []string{table},
|
||||
"format": []string{format},
|
||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||
},
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: appDataExportPath(appID),
|
||||
QueryParams: exportQuery,
|
||||
})
|
||||
if err != nil {
|
||||
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(符合条件的记录总数)。
|
||||
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
||||
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
|
||||
map[string]interface{}{"env": env, "page_size": 1}, nil)
|
||||
params := map[string]interface{}{"page_size": 1}
|
||||
if env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
|
||||
{Name: "app-id", Desc: "Miaoda app id", 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)"},
|
||||
}, 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 {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appDataImportPath(appID)).
|
||||
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>"})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -100,10 +100,14 @@ var AppsDBDataImport = common.Shortcut{
|
||||
fd.AddField("file_name", fileName)
|
||||
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{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: appDataImportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
|
||||
QueryParams: importQuery,
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
|
||||
@@ -121,6 +121,31 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 dry-run 的 query
|
||||
// 不带 env 键(交服务端按应用形态自动选分支),但仍携带 table。
|
||||
func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
p := env.API[0].Params
|
||||
if _, ok := p["env"]; ok {
|
||||
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
|
||||
}
|
||||
if p["table"] != "orders" {
|
||||
t.Fatalf("table should still default to file basename, got params=%v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
||||
func TestAppsDBDataImport_Success(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
|
||||
@@ -97,6 +97,16 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 先 dry_run 预览拿待发布变更数(对齐 miaoda-cli 的 diff-then-apply):服务端在未经
|
||||
// dry_run 预热时直接 apply,虽发布成功却把 changes_applied 回填成 0(展示「Migrated (0 changes)」)。
|
||||
// 这一步既预热服务端计数、又作为 apply 仍回 0 时的兜底数。dry_run 报错(如无待发布变更)不阻断,
|
||||
// 交由下面真实 apply 统一报同样的业务错。
|
||||
pending := 0
|
||||
var previewFrom, previewTo string
|
||||
if preview, perr := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}); perr == nil {
|
||||
pending = len(projectMigrationChanges(preview["changes"]))
|
||||
previewFrom, previewTo = common.GetString(preview, "from"), common.GetString(preview, "to")
|
||||
}
|
||||
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
||||
@@ -104,6 +114,12 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||||
if from == "" {
|
||||
from = previewFrom
|
||||
}
|
||||
if to == "" {
|
||||
to = previewTo
|
||||
}
|
||||
taskID := common.GetString(submit, "task_id")
|
||||
applied := intFromAny(submit["changes_applied"])
|
||||
if applied == 0 {
|
||||
@@ -131,6 +147,10 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
applied = n
|
||||
}
|
||||
}
|
||||
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
|
||||
if applied == 0 && pending > 0 {
|
||||
applied = pending
|
||||
}
|
||||
stop() // clear spinner before printing the result
|
||||
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
|
||||
@@ -105,8 +105,10 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
||||
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
||||
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -126,8 +128,10 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
||||
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -319,6 +323,31 @@ func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
|
||||
}
|
||||
|
||||
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
||||
// TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 quota-get 的 dry-run
|
||||
// query 不带 env 键(交服务端按应用形态自动选分支)。
|
||||
func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbQuotaURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if _, ok := a.Params["env"]; ok {
|
||||
t.Fatalf("no --environment → env key must be omitted, got params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
|
||||
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
||||
Input: []string{common.Stdin}},
|
||||
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
||||
}, 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 {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -291,10 +291,9 @@ func parseErrorSentinel(data string) (int, string) {
|
||||
//
|
||||
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
||||
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
return dbEnvParams(rctx, map[string]interface{}{
|
||||
"transactional": false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
||||
|
||||
@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", 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 {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -41,14 +41,14 @@ var AppsDBQuotaGet = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDbQuotaPath(appID)).
|
||||
Desc("Get Miaoda app database storage usage").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)})
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
|
||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
|
||||
@@ -32,19 +32,23 @@ var AppsDBRecoveryDiff = common.Shortcut{
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
}, 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 {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -81,19 +85,23 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
}, 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 {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -104,7 +112,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
target := rctx.Str("target")
|
||||
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
@@ -119,7 +127,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
}
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
@@ -157,7 +165,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
||||
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true})
|
||||
if err != nil {
|
||||
return nil, withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
@@ -167,7 +175,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
|
||||
}
|
||||
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||
@@ -195,13 +203,13 @@ type recoveryChange struct {
|
||||
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
||||
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
||||
arr, _ := preview["changes"].([]interface{})
|
||||
changes := make([]recoveryChange, 0, len(arr))
|
||||
raw := make([]recoveryChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, recoveryChange{
|
||||
raw = append(raw, recoveryChange{
|
||||
Table: common.GetString(m, "table"),
|
||||
Inserted: m["inserted"],
|
||||
Deleted: m["deleted"],
|
||||
@@ -209,16 +217,33 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
|
||||
DroppedAt: common.GetString(m, "dropped_at"),
|
||||
})
|
||||
}
|
||||
tablesAffected := intFromAny(preview["tables_affected"])
|
||||
if tablesAffected == 0 {
|
||||
tablesAffected = len(changes)
|
||||
// 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。
|
||||
// schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表
|
||||
// 两行 + tables_affected 翻倍。
|
||||
hasSchema := map[string]bool{}
|
||||
for _, c := range raw {
|
||||
if c.Action != "" {
|
||||
hasSchema[c.Table] = true
|
||||
}
|
||||
}
|
||||
changes := make([]recoveryChange, 0, len(raw))
|
||||
for _, c := range raw {
|
||||
if c.Action == "" && hasSchema[c.Table] {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, c)
|
||||
}
|
||||
// tables_affected 按去重后的不同表数计(而非变更条数)。
|
||||
seen := map[string]bool{}
|
||||
for _, c := range changes {
|
||||
seen[c.Table] = true
|
||||
}
|
||||
est := intFromAny(preview["estimated_seconds"])
|
||||
if est == 0 {
|
||||
est = 30 // PRD 兜底
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"target": target, "tables_affected": tablesAffected,
|
||||
"target": target, "tables_affected": len(seen),
|
||||
"changes": changes, "estimated_seconds": est,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "table", Desc: "table name", 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 {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -80,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本;
|
||||
// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。
|
||||
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
||||
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||
if rctx.Format == "pretty" {
|
||||
params["format"] = "ddl"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -42,7 +43,7 @@ var AppsDBTableList = common.Shortcut{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{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 {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -110,10 +111,9 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
|
||||
}
|
||||
|
||||
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
@@ -286,6 +286,17 @@ func numericAsFloat(raw interface{}) (float64, bool) {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case string:
|
||||
// 服务端有些数值字段(如 recovery diff 的 inserted/deleted 行数)以字符串下发。
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case nil:
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -236,7 +236,11 @@ func TestNumericAsFloat_AllTypes(t *testing.T) {
|
||||
{"json.Number valid", json.Number("13.5"), 13.5, true},
|
||||
{"json.Number invalid", json.Number("abc"), 0, false},
|
||||
{"nil", nil, 0, false},
|
||||
{"unsupported string", "x", 0, false},
|
||||
{"non-numeric string", "x", 0, false},
|
||||
{"numeric string", "13.5", 13.5, true},
|
||||
{"numeric string int", "2", 2, true},
|
||||
{"numeric string padded", " 13.5 ", 13.5, true},
|
||||
{"empty string", "", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
||||
@@ -34,6 +34,16 @@ func dbEnv(rctx *common.RuntimeContext) string {
|
||||
return rctx.Str("environment")
|
||||
}
|
||||
|
||||
// dbEnvParams 把 env 并入 params:仅当显式指定了环境(非空)才带 env 键;未指定(空)时
|
||||
// 省略该键,由服务端按应用多环境状态自动选分支(多环境→dev,单环境→online)。与家族对
|
||||
// 空可选参数的 omit-empty 约定一致——不发空串,wire 上真正不带 env。原样返回同一个 map 便于链式。
|
||||
func dbEnvParams(rctx *common.RuntimeContext, params map[string]interface{}) map[string]interface{} {
|
||||
if env := dbEnv(rctx); env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env:显式传了就报清晰的 validation 错,指向 --environment。
|
||||
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
|
||||
if rctx.Changed("env") {
|
||||
|
||||
@@ -89,6 +89,18 @@ func TestDryRunFieldOps(t *testing.T) {
|
||||
)
|
||||
assertDryRunContains(t, dryRunFieldGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
assertDryRunContains(t, dryRunFieldCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/fields")
|
||||
|
||||
arrayRT := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"json": `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunFieldCreate(ctx, arrayRT), `"name":"A"`, `"name":"B"`)
|
||||
|
||||
assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open")
|
||||
|
||||
@@ -830,11 +830,6 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "field create",
|
||||
shortcut: BaseFieldCreate,
|
||||
args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "field update",
|
||||
shortcut: BaseFieldUpdate,
|
||||
@@ -1102,6 +1097,54 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create array sequentially", func(t *testing.T) {
|
||||
oldDelay := fieldCreateBatchDelay
|
||||
fieldCreateBatchDelay = 0
|
||||
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
|
||||
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
firstStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"name":"A"`)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_a", "name": "A", "type": "text"},
|
||||
},
|
||||
}
|
||||
secondStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"name":"B"`)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_b", "name": "B", "type": "text"},
|
||||
},
|
||||
}
|
||||
reg.Register(firstStub)
|
||||
reg.Register(secondStub)
|
||||
|
||||
err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["created"] != true || data["total"] != float64(2) {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
fields, _ := data["fields"].([]interface{})
|
||||
if len(fields) != 2 {
|
||||
t.Fatalf("fields len=%d output=%#v", len(fields), data)
|
||||
}
|
||||
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
|
||||
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -1060,6 +1060,15 @@ func TestBaseFieldValidate(t *testing.T) {
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"text"},{"name":"b","type":"text"}]`}, nil, nil)); err != nil {
|
||||
t.Fatalf("array create validate err=%v", err)
|
||||
}
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"text"},1]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json item 2 must be an object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"formula"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ package base
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var fieldCreateBatchDelay = time.Second
|
||||
|
||||
func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
offset := runtime.Int("offset")
|
||||
if offset < 0 {
|
||||
@@ -33,12 +36,14 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
|
||||
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").
|
||||
Body(body).
|
||||
bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
|
||||
dr := common.NewDryRunAPI().
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
for _, body := range bodies {
|
||||
dr.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").Body(body)
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -95,11 +100,16 @@ func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command strin
|
||||
}
|
||||
|
||||
func validateFieldCreate(runtime *common.RuntimeContext) error {
|
||||
body, err := validateFieldJSON(runtime)
|
||||
bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateFormulaLookupGuideAck(runtime, "+field-create", body)
|
||||
for _, body := range bodies {
|
||||
if err := validateFormulaLookupGuideAck(runtime, "+field-create", body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFieldUpdate(runtime *common.RuntimeContext) error {
|
||||
@@ -140,19 +150,40 @@ func executeFieldGet(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeFieldCreate(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
fields := make([]interface{}, 0, len(bodies))
|
||||
for idx, body := range bodies {
|
||||
if idx > 0 && fieldCreateBatchDelay > 0 {
|
||||
time.Sleep(fieldCreateBatchDelay)
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields = append(fields, data)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"field": data, "created": true}, nil)
|
||||
if len(fields) == 1 {
|
||||
runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
|
||||
return nil
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseFieldCreateBodies(pc *parseCtx, raw string) ([]map[string]interface{}, error) {
|
||||
bodies, err := parseObjectList(pc, raw, "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(bodies) == 0 {
|
||||
return nil, baseFlagErrorf("--json must contain at least one field JSON object")
|
||||
}
|
||||
return bodies, nil
|
||||
}
|
||||
|
||||
func executeFieldUpdate(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -67,6 +67,26 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
||||
return attendees, nil
|
||||
}
|
||||
|
||||
func attendeesIncludeRoom(attendees []map[string]string) bool {
|
||||
for _, attendee := range attendees {
|
||||
if attendee["type"] == "resource" || attendee["room_id"] != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func guideApprovalRoomReasonError(err error, attendees []map[string]string) error {
|
||||
if err == nil || !attendeesIncludeRoom(attendees) {
|
||||
return err
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || !strings.Contains(strings.ToLower(p.Hint), "approval_reason") {
|
||||
return err
|
||||
}
|
||||
return withStepContext(err, "approval meeting rooms require attendees[].approval_reason; calendar +create does not expose this low-frequency field. Create the event with the raw API flow, then use `lark-cli calendar event.attendees create --as user` with attendees[].approval_reason for the room attendee.")
|
||||
}
|
||||
|
||||
var CalendarCreate = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+create",
|
||||
@@ -225,6 +245,7 @@ var CalendarCreate = common.Shortcut{
|
||||
"need_notification": true,
|
||||
})
|
||||
if err != nil {
|
||||
err = guideApprovalRoomReasonError(err, attendees)
|
||||
// Rollback: delete the event
|
||||
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||
@@ -285,6 +306,9 @@ var CalendarCreate = common.Shortcut{
|
||||
"start": startStr,
|
||||
"end": endStr,
|
||||
}
|
||||
if recurrence, _ := event["recurrence"].(string); recurrence != "" {
|
||||
resultData["recurrence"] = recurrence
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
||||
var rows []map[string]interface{}
|
||||
|
||||
279
shortcuts/calendar/calendar_get.go
Normal file
279
shortcuts/calendar/calendar_get.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// calendar +get — get a single calendar event detail by calendar_id and event_id
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// calendarEventTime mirrors start_time / end_time in the API response.
|
||||
type calendarEventTime struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventVChat mirrors the vchat block in the API response.
|
||||
type calendarEventVChat struct {
|
||||
VCType string `json:"vc_type,omitempty"`
|
||||
IconType string `json:"icon_type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
MeetingURL string `json:"meeting_url,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventLocation mirrors the location block in the API response.
|
||||
type calendarEventLocation struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Latitude float64 `json:"latitude,omitempty"`
|
||||
Longitude float64 `json:"longitude,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventReminder mirrors a reminder entry.
|
||||
type calendarEventReminder struct {
|
||||
Minutes int `json:"minutes"`
|
||||
}
|
||||
|
||||
// calendarEventOrganizer mirrors event_organizer.
|
||||
type calendarEventOrganizer struct {
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventAttachment mirrors a single attachment entry.
|
||||
type calendarEventAttachment struct {
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
FileSize string `json:"file_size,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventCheckInTime mirrors check_in_start_time / check_in_end_time.
|
||||
type calendarEventCheckInTime struct {
|
||||
TimeType string `json:"time_type,omitempty"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
// calendarEventCheckIn mirrors event_check_in.
|
||||
type calendarEventCheckIn struct {
|
||||
EnableCheckIn bool `json:"enable_check_in"`
|
||||
CheckInStartTime *calendarEventCheckInTime `json:"check_in_start_time,omitempty"`
|
||||
CheckInEndTime *calendarEventCheckInTime `json:"check_in_end_time,omitempty"`
|
||||
NeedNotifyAttendees bool `json:"need_notify_attendees"`
|
||||
}
|
||||
|
||||
// calendarEvent mirrors the event object inside the API response.
|
||||
type calendarEvent struct {
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
||||
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
||||
VChat *calendarEventVChat `json:"vchat,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
AttendeeAbility string `json:"attendee_ability,omitempty"`
|
||||
FreeBusyStatus string `json:"free_busy_status,omitempty"`
|
||||
SelfRsvpStatus string `json:"self_rsvp_status,omitempty"`
|
||||
Location *calendarEventLocation `json:"location,omitempty"`
|
||||
Color int `json:"color,omitempty"`
|
||||
Reminders []calendarEventReminder `json:"reminders,omitempty"`
|
||||
Recurrence string `json:"recurrence,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
IsException bool `json:"is_exception,omitempty"`
|
||||
RecurringEventID string `json:"recurring_event_id,omitempty"`
|
||||
CreateTime string `json:"create_time,omitempty"`
|
||||
EventOrganizer *calendarEventOrganizer `json:"event_organizer,omitempty"`
|
||||
AppLink string `json:"app_link,omitempty"`
|
||||
Attachments []calendarEventAttachment `json:"attachments,omitempty"`
|
||||
EventCheckIn *calendarEventCheckIn `json:"event_check_in,omitempty"`
|
||||
}
|
||||
|
||||
// parseCalendarEvent decodes the API response data into a typed calendarEvent.
|
||||
func parseCalendarEvent(data map[string]any) (*calendarEvent, error) {
|
||||
rawEvent, ok := data["event"]
|
||||
if !ok || rawEvent == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
|
||||
}
|
||||
raw, err := json.Marshal(rawEvent)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: marshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
var event calendarEvent
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: unmarshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
// buildCalendarEventOutput converts the typed event into the output map and
|
||||
// applies the four transformation rules:
|
||||
// 1. create_time -> RFC3339
|
||||
// 2. start_time / end_time timestamp -> datetime (RFC3339), drop timestamp
|
||||
// 3. flatten event into the top-level result
|
||||
// 4. when status != "cancelled", drop status (and adjust all-day end date)
|
||||
func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, error) {
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event marshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event unmarshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
if ctStr, ok := out["create_time"].(string); ok && ctStr != "" {
|
||||
if ts, err := strconv.ParseInt(ctStr, 10, 64); err == nil {
|
||||
out["create_time"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
if startMap, ok := out["start_time"].(map[string]interface{}); ok {
|
||||
if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" {
|
||||
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||
startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
delete(startMap, "timestamp")
|
||||
}
|
||||
}
|
||||
}
|
||||
if endMap, ok := out["end_time"].(map[string]interface{}); ok {
|
||||
if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" {
|
||||
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||
endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
delete(endMap, "timestamp")
|
||||
}
|
||||
}
|
||||
// All-day event: end date is exclusive in the API; rewind by 1s and reformat.
|
||||
if dt, _ := endMap["datetime"].(string); dt == "" {
|
||||
if dateStr, ok := endMap["date"].(string); ok && dateStr != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil {
|
||||
endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if status, _ := out["status"].(string); status != "cancelled" {
|
||||
delete(out, "status")
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CalendarGet gets a single calendar event detail.
|
||||
var CalendarGet = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+get",
|
||||
Description: "Get a single calendar event detail by calendar-id and event-id",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.event:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "event-id", Desc: "event ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, flag := range []string{"calendar-id", "event-id"} {
|
||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
if eventId == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
d := common.NewDryRunAPI()
|
||||
switch calendarId {
|
||||
case "":
|
||||
d.Desc("(calendar-id omitted) Will use primary calendar")
|
||||
calendarId = "<primary>"
|
||||
case "primary":
|
||||
calendarId = "<primary>"
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
return d.
|
||||
GET("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
|
||||
Set("calendar_id", calendarId).
|
||||
Set("event_id", eventId)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
if calendarId == "" {
|
||||
calendarId = PrimaryCalendarIDStr
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
|
||||
data, err := runtime.CallAPITyped("GET",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
|
||||
validate.EncodePathSegment(calendarId),
|
||||
validate.EncodePathSegment(eventId)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event, err := parseCalendarEvent(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := buildCalendarEventOutput(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
summary, _ := out["summary"].(string)
|
||||
if summary == "" {
|
||||
summary = "(untitled)"
|
||||
}
|
||||
startMap, _ := out["start_time"].(map[string]interface{})
|
||||
endMap, _ := out["end_time"].(map[string]interface{})
|
||||
startStr, _ := startMap["datetime"].(string)
|
||||
if startStr == "" {
|
||||
startStr, _ = startMap["date"].(string)
|
||||
}
|
||||
endStr, _ := endMap["datetime"].(string)
|
||||
if endStr == "" {
|
||||
endStr, _ = endMap["date"].(string)
|
||||
}
|
||||
eventIdOut, _ := out["event_id"].(string)
|
||||
freeBusyStatus, _ := out["free_busy_status"].(string)
|
||||
selfRsvpStatus, _ := out["self_rsvp_status"].(string)
|
||||
row := map[string]interface{}{
|
||||
"event_id": eventIdOut,
|
||||
"summary": summary,
|
||||
"start": startStr,
|
||||
"end": endStr,
|
||||
"free_busy_status": freeBusyStatus,
|
||||
"self_rsvp_status": selfRsvpStatus,
|
||||
}
|
||||
output.PrintTable(w, []map[string]interface{}{row})
|
||||
fmt.Fprintln(w)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -673,6 +673,76 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_ApprovalRoomMissingReason_GuidesRawAttendeesAPI(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_approval_room",
|
||||
"summary": "Approval Room",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/events/evt_approval_room/attendees",
|
||||
Body: map[string]interface{}{
|
||||
"code": codeInvalidParamsWithDetail,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]interface{}{
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "attendees[0].approval_reason is required for approval meeting rooms"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/events/evt_approval_room",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Approval Room",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--attendee-ids", "omm_room1",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for approval room missing approval_reason, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok for %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category=%q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidParameters {
|
||||
t.Errorf("subtype=%q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
|
||||
}
|
||||
if p.Code != codeInvalidParamsWithDetail {
|
||||
t.Errorf("code=%d, want %d", p.Code, codeInvalidParamsWithDetail)
|
||||
}
|
||||
for _, want := range []string{"approval_reason", "calendar event.attendees create", "--as user", "rolled back successfully"} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When the add-attendees call fails AND the rollback DELETE also fails, the
|
||||
// primary error stays the add failure (classification preserved) and the Hint
|
||||
// must surface BOTH the rollback failure reason and the orphan event_id so the
|
||||
@@ -2234,17 +2304,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
||||
// Shortcuts() registration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShortcuts_Returns9(t *testing.T) {
|
||||
func TestShortcuts_Returns10(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 9 {
|
||||
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
|
||||
if len(shortcuts) != 10 {
|
||||
t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, s := range shortcuts {
|
||||
names[s.Command] = true
|
||||
}
|
||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
|
||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing shortcut %s", want)
|
||||
}
|
||||
@@ -3108,3 +3178,193 @@ func TestSuggestion_RejectsDangerousTimezone_Typed(t *testing.T) {
|
||||
t.Errorf("param=%q, want --timezone", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarGet tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_001",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_001",
|
||||
"summary": "Daily Sync",
|
||||
"create_time": "1602504000",
|
||||
"start_time": map[string]interface{}{
|
||||
"timestamp": "1742515200",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"end_time": map[string]interface{}{
|
||||
"timestamp": "1742518800",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"status": "confirmed",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_001",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// Expect flattened — fields appear directly under "data", not under "data.event"
|
||||
if strings.Contains(out, "\"event\": {") {
|
||||
t.Errorf("payload should be flattened (no event wrapper), got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"event_id\": \"evt_001\"") {
|
||||
t.Errorf("expected event_id in output, got: %s", out)
|
||||
}
|
||||
// status=confirmed should be dropped
|
||||
if strings.Contains(out, "\"status\": \"confirmed\"") {
|
||||
t.Errorf("status should be dropped when not cancelled, got: %s", out)
|
||||
}
|
||||
// timestamp must be replaced with datetime
|
||||
if strings.Contains(out, "\"timestamp\":") {
|
||||
t.Errorf("timestamp should be replaced with datetime, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"datetime\":") {
|
||||
t.Errorf("expected datetime in output, got: %s", out)
|
||||
}
|
||||
// create_time must be RFC3339 (contain 'T' and timezone)
|
||||
if !strings.Contains(out, "\"create_time\": \"2020-10-12T") {
|
||||
t.Errorf("expected RFC3339 create_time, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_002",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_002",
|
||||
"summary": "Cancelled Meeting",
|
||||
"create_time": "1602504000",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
"status": "cancelled",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_002",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "\"status\": \"cancelled\"") {
|
||||
t.Errorf("status should be preserved when cancelled, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_AllDayEvent_AdjustsEndDate(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// All-day event: start 2025-03-21, end 2025-03-22 (exclusive in API).
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_003",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_003",
|
||||
"summary": "All-day",
|
||||
"start_time": map[string]interface{}{"date": "2025-03-21"},
|
||||
"end_time": map[string]interface{}{"date": "2025-03-22"},
|
||||
"status": "confirmed",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_003",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// end date 2025-03-22 should rewind by 1s -> 2025-03-21
|
||||
if !strings.Contains(out, "\"date\": \"2025-03-21\"") {
|
||||
t.Errorf("expected end date adjusted to 2025-03-21, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_EmptyEventID_Typed(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--event-id", " ",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want error for empty event-id")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Param != "--event-id" {
|
||||
t.Errorf("param=%q, want --event-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_MissingEventField_TypedInternal(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_404",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_404",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want error when event field is missing")
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("want *errs.InternalError, got %T", err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarSuggestion,
|
||||
CalendarMeeting,
|
||||
CalendarSearchEvent,
|
||||
CalendarGet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,6 +889,7 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
}
|
||||
}
|
||||
cmdmeta.SetSource(cmd, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetAffordanceRef(cmd, shortcut.Service, shortcut.Command)
|
||||
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
|
||||
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
|
||||
cmdutil.SetTips(cmd, shortcut.Tips)
|
||||
|
||||
@@ -150,12 +150,10 @@ var ContactSearchUser = common.Shortcut{
|
||||
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Keyword search: lark-cli contact +search-user --query 'alice'",
|
||||
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'",
|
||||
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted",
|
||||
"Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users",
|
||||
"Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'",
|
||||
"open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.",
|
||||
"on has_more=true add filters or tighten --query — there is no auto-pagination.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateSearchUser(runtime)
|
||||
|
||||
@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
|
||||
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
|
||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
|
||||
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
|
||||
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
@@ -18,6 +18,7 @@ func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
|
||||
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
|
||||
{Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"},
|
||||
@@ -32,8 +33,8 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("title") && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
|
||||
}
|
||||
if runtime.Str("content") == "" && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
|
||||
if err := validateDocsV2ReferenceMapFlags(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
|
||||
@@ -41,11 +42,21 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"},
|
||||
)
|
||||
}
|
||||
if runtime.Str("content") == "" && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
|
||||
}
|
||||
if runtime.Str("content") != "" {
|
||||
_, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildCreateBody(runtime)
|
||||
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
desc := "OpenAPI: create document"
|
||||
if runtime.IsBot() {
|
||||
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document."
|
||||
@@ -57,7 +68,10 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
}
|
||||
|
||||
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
body := buildCreateBody(runtime)
|
||||
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
|
||||
if err != nil {
|
||||
@@ -86,7 +100,10 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
}
|
||||
|
||||
func buildCreateContent(runtime *common.RuntimeContext) string {
|
||||
content := runtime.Str("content")
|
||||
return buildCreateContentWithBody(runtime, runtime.Str("content"))
|
||||
}
|
||||
|
||||
func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) string {
|
||||
title := strings.TrimSpace(runtime.Str("title"))
|
||||
if title == "" {
|
||||
return content
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
|
||||
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`
|
||||
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
@@ -71,6 +71,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := processHTML5BlockReferenceMapForFetch(runtime, effectiveFetchFormat(runtime), ref.Token, data); err != nil {
|
||||
return err
|
||||
}
|
||||
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
|
||||
}
|
||||
|
||||
@@ -505,14 +505,14 @@ func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
|
||||
if got["enable_user_cite_reference_map"] != true {
|
||||
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
|
||||
}
|
||||
if _, ok := got["return_html5_block_data"]; ok {
|
||||
t.Fatalf("extra_param should not request html5 block data: %#v", got)
|
||||
if got["return_html5_block_data"] != true {
|
||||
t.Fatalf("return_html5_block_data = %#v, want true in %#v", got["return_html5_block_data"], got)
|
||||
}
|
||||
if _, ok := got["reference_map_mode"]; ok {
|
||||
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("extra_param should only contain fetch reference_map and html5 data toggles: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,6 +579,46 @@ func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchIMMarkdownIgnoresHTML5BlockInsideCodeFence(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown-code-fence"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdownFence/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchIMMarkdownFence",
|
||||
"revision_id": float64(1),
|
||||
"content": "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```\n",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchIMMarkdownFence",
|
||||
"--doc-format", "im-markdown",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if errField, ok := envelope["error"]; ok {
|
||||
t.Fatalf("fetch output should not contain error: %#v", errField)
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
content, _ := doc["content"].(string)
|
||||
if !strings.Contains(content, "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```") {
|
||||
t.Fatalf("fenced html5-block should stay in content, got:\n%s", content)
|
||||
}
|
||||
if _, ok := doc["reference_map"]; ok {
|
||||
t.Fatalf("fenced html5-block should not create reference_map side effects: %#v", doc["reference_map"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
261
shortcuts/doc/docs_history.go
Normal file
261
shortcuts/doc/docs_history.go
Normal file
@@ -0,0 +1,261 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type docsHistoryListSpec struct {
|
||||
Doc documentRef
|
||||
PageSize int
|
||||
PageToken string
|
||||
}
|
||||
|
||||
type docsHistoryRevertSpec struct {
|
||||
Doc documentRef
|
||||
HistoryVersionID string
|
||||
WaitTimeoutMs int
|
||||
}
|
||||
|
||||
type docsHistoryRevertStatusSpec struct {
|
||||
Doc documentRef
|
||||
TaskID string
|
||||
}
|
||||
|
||||
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
|
||||
ref, err := parseDocumentRef(raw)
|
||||
if err != nil {
|
||||
return documentRef{}, err
|
||||
}
|
||||
if ref.Kind == "doc" {
|
||||
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryPageSize(pageSize int) error {
|
||||
if pageSize < 1 || pageSize > 20 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryVersionID(historyVersionID string) error {
|
||||
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
|
||||
}
|
||||
if version <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
|
||||
if timeoutMs < 0 || timeoutMs > 30000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": spec.PageSize,
|
||||
}
|
||||
if spec.PageToken != "" {
|
||||
params["page_token"] = spec.PageToken
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"history_version_id": spec.HistoryVersionID,
|
||||
"wait_timeout_ms": spec.WaitTimeoutMs,
|
||||
}
|
||||
}
|
||||
|
||||
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"task_id": spec.TaskID,
|
||||
}
|
||||
}
|
||||
|
||||
func docsHistoryAPIPath(docToken, suffix string) string {
|
||||
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
|
||||
}
|
||||
|
||||
var DocsHistoryList = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-list",
|
||||
Description: "List Lark document history versions",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-list"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
|
||||
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDocsHistoryPageSize(runtime.Int("page-size"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||
spec := docsHistoryListSpec{
|
||||
Doc: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: list document history versions").
|
||||
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Params(docsHistoryListParams(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||
spec := docsHistoryListSpec{
|
||||
Doc: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "histories"),
|
||||
docsHistoryListParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocsHistoryRevert = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-revert",
|
||||
Description: "Revert a Lark document to a historical version",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-revert"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
|
||||
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||
spec := docsHistoryRevertSpec{
|
||||
Doc: ref,
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: revert document history").
|
||||
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Body(docsHistoryRevertBody(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||
spec := docsHistoryRevertSpec{
|
||||
Doc: ref,
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodPost,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
|
||||
nil,
|
||||
docsHistoryRevertBody(spec),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocsHistoryRevertStatus = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Get Lark document history revert task status",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-revert-status"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("task-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||
spec := docsHistoryRevertStatusSpec{
|
||||
Doc: ref,
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: get document history revert status").
|
||||
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Params(docsHistoryStatusParams(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||
spec := docsHistoryRevertStatusSpec{
|
||||
Doc: ref,
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
|
||||
docsHistoryStatusParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user