mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +08:00
Compare commits
2 Commits
feat/affor
...
fix/wiki-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd03fbaa00 | ||
|
|
b48290c5db |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -263,19 +263,13 @@ 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:
|
||||
@@ -283,28 +277,7 @@ jobs:
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
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
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
@@ -319,22 +292,15 @@ 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"
|
||||
@@ -344,24 +310,16 @@ 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: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No live CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ -z "$packages" ]; then
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
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
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
if: ${{ !cancelled() }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,9 +27,6 @@ Thumbs.db
|
||||
# Go
|
||||
docs/ref
|
||||
docs/
|
||||
!tests/cli_e2e/docs/
|
||||
!tests/cli_e2e/docs/*.go
|
||||
!tests/cli_e2e/docs/*.md
|
||||
vendor/
|
||||
|
||||
|
||||
|
||||
68
CHANGELOG.md
68
CHANGELOG.md
@@ -2,71 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
@@ -1398,9 +1333,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
|
||||
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/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
|
||||
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
|
||||
18
README.md
18
README.md
@@ -233,24 +233,6 @@ 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,24 +234,6 @@ 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,33 +10,18 @@ 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>`; a
|
||||
+-prefixed heading (## +create) targets that shortcut
|
||||
## <command> the command as typed, minus `lark-cli <domain>`
|
||||
<lead paragraph> when to use this command
|
||||
### Avoid when when not to use it / which command to use instead
|
||||
### Prerequisites what you must have first (e.g. an id, and where it comes from)
|
||||
### Tips gotchas and constraints
|
||||
### Examples **description** lines, each followed by a fenced command
|
||||
### 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
|
||||
@@ -62,5 +47,3 @@ replace the Go tips (not merged), so keep tips in one place.
|
||||
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,42 +1,6 @@
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -20,28 +20,13 @@ 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 := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -69,7 +54,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -92,7 +77,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||
@@ -113,7 +98,7 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -140,7 +125,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET"}) // missing path
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -153,7 +138,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -166,7 +151,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
fn := cmd.ValidArgsFunction
|
||||
|
||||
tests := []struct {
|
||||
@@ -232,7 +217,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
@@ -251,7 +236,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -270,7 +255,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -287,7 +272,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return apiRun(opts)
|
||||
})
|
||||
@@ -312,7 +297,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
ContentType: "application/octet-stream",
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -343,7 +328,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -383,7 +368,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(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
|
||||
@@ -424,7 +409,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -463,7 +448,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -498,7 +483,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -564,8 +549,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -615,8 +600,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -671,8 +656,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
@@ -736,7 +721,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -756,7 +741,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -775,7 +760,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||
@@ -806,7 +791,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -827,7 +812,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||
@@ -845,7 +830,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||
@@ -874,7 +859,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(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 {
|
||||
@@ -895,7 +880,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -914,7 +899,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -932,7 +917,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||
@@ -949,7 +934,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||
@@ -966,7 +951,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||
@@ -989,7 +974,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(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 {
|
||||
@@ -1030,7 +1015,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -1056,7 +1041,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -80,6 +82,40 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_WikiNodeGetSuggestsNodeToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
||||
root.SetArgs([]string{
|
||||
"wiki", "+node-get",
|
||||
"--node", "https://feishu.cn/wiki/wikcnABC",
|
||||
"--as", "user",
|
||||
})
|
||||
|
||||
err := root.Execute()
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--node" {
|
||||
t.Fatalf("Params = %v, want one entry named --node", verr.Params)
|
||||
}
|
||||
found := false
|
||||
for _, s := range verr.Params[0].Suggestions {
|
||||
if s == "--node-token" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("Params[0].Suggestions = %v, want --node-token", verr.Params[0].Suggestions)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "--node-token") {
|
||||
t.Fatalf("hint = %q, want --node-token", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||
|
||||
@@ -679,11 +679,7 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
|
||||
if service.PrepareMethodHelp(cmd) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,13 +14,11 @@ 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"
|
||||
@@ -105,11 +103,6 @@ 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
|
||||
@@ -120,11 +113,7 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
if catalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||
} else {
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
}
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
@@ -132,29 +121,6 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
|
||||
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, "")
|
||||
@@ -389,11 +355,10 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
return cmd
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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).
|
||||
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.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if noSpace {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
@@ -86,19 +86,13 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||
}
|
||||
|
||||
// runSchema resolves the path through the schema catalog and renders the
|
||||
// runSchema resolves the path through the embedded 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.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")
|
||||
}
|
||||
catalog := registry.EmbeddedCatalog()
|
||||
target, err := catalog.Resolve(parts)
|
||||
if err != nil {
|
||||
return resolveError(err)
|
||||
|
||||
@@ -71,18 +71,11 @@ 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.
|
||||
// hand-authored Long when present, else the Short — captured once into an
|
||||
// annotation so re-rendering reuses the pristine text instead of the
|
||||
// already-augmented Long.
|
||||
func domainHelpBase(cmd *cobra.Command) string {
|
||||
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 {
|
||||
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
|
||||
return base
|
||||
}
|
||||
base := cmd.Long
|
||||
@@ -92,7 +85,7 @@ func captureHelpBase(cmd *cobra.Command, key string) string {
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[key] = base
|
||||
cmd.Annotations[domainBaseAnnotation] = base
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -108,12 +101,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 (
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
shortcutBaseAnnotation = "affordance-shortcut-base"
|
||||
affordanceServiceAnnotation = "affordance-service"
|
||||
affordanceMethodAnnotation = "affordance-method"
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
)
|
||||
|
||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||
@@ -122,7 +115,10 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmdmeta.SetAffordanceRef(cmd, service, methodID)
|
||||
if service != "" && methodID != "" {
|
||||
cmd.Annotations[affordanceServiceAnnotation] = service
|
||||
cmd.Annotations[affordanceMethodAnnotation] = methodID
|
||||
}
|
||||
cmd.Annotations[schemaPathAnnotation] = schemaPath
|
||||
if paramsOnly != "" {
|
||||
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
|
||||
@@ -132,11 +128,8 @@ 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. 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 {
|
||||
// here — only when help is rendered.
|
||||
func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
ann := cmd.Annotations
|
||||
if ann == nil {
|
||||
return false
|
||||
@@ -148,15 +141,22 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(cmd.Short)
|
||||
writeRisk(&b, cmd)
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
// --yes asserts the USER confirmed; the agent must not self-approve.
|
||||
if level == cmdutil.RiskHighRiskWrite {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s", level)
|
||||
}
|
||||
}
|
||||
|
||||
var skills []string
|
||||
if raw, ok := affordanceRaw(cmd); ok {
|
||||
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
|
||||
if block := renderAffordanceValue(a); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
skills = a.Skills
|
||||
}
|
||||
}
|
||||
@@ -164,93 +164,15 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(ann[paramsOnlyAnnotation])
|
||||
|
||||
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(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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// affordanceLookup is the overlay source; a package var so tests can inject.
|
||||
@@ -267,8 +189,12 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string {
|
||||
}
|
||||
|
||||
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
|
||||
service, methodID, ok := cmdmeta.AffordanceRef(cmd)
|
||||
if !ok {
|
||||
if cmd.Annotations == nil {
|
||||
return nil, false
|
||||
}
|
||||
service := cmd.Annotations[affordanceServiceAnnotation]
|
||||
methodID := cmd.Annotations[affordanceMethodAnnotation]
|
||||
if service == "" || methodID == "" {
|
||||
return nil, false
|
||||
}
|
||||
return affordanceLookup(service, methodID)
|
||||
@@ -281,13 +207,7 @@ 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,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -71,8 +70,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 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)
|
||||
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
|
||||
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +119,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, nil) {
|
||||
if !PrepareMethodHelp(cmd) {
|
||||
t.Fatal("PrepareMethodHelp returned false for a service-method command")
|
||||
}
|
||||
long := cmd.Long
|
||||
@@ -137,133 +136,11 @@ func TestPrepareMethodHelp(t *testing.T) {
|
||||
}
|
||||
|
||||
// A non-service command (no schema-path annotation) is left untouched.
|
||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}, nil) {
|
||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
|
||||
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 {
|
||||
|
||||
@@ -102,8 +102,7 @@ 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>
|
||||
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- manual/other: shows GitHub Releases download URL
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
@@ -165,7 +164,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
if !detect.CanAutoUpdate() {
|
||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||
}
|
||||
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
||||
}
|
||||
|
||||
// --- Output helpers ---
|
||||
@@ -227,23 +226,12 @@ 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())
|
||||
if detect.Method == selfupdate.InstallPnpm {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
||||
restore, err := updater.PrepareSelfReplace()
|
||||
if err != nil {
|
||||
return reportError(opts, io, "update_error",
|
||||
@@ -251,19 +239,19 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
|
||||
}
|
||||
|
||||
if !opts.JSON {
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
||||
}
|
||||
|
||||
npmResult := install(latest)
|
||||
npmResult := updater.RunNpmInstall(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("%s install failed: %s", pm, npmResult.Err),
|
||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||
"hint": permissionHint(combined, pm),
|
||||
"hint": permissionHint(combined),
|
||||
},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
@@ -275,7 +263,7 @@ func doAutoUpdate(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, pm); hint != "" {
|
||||
if hint := permissionHint(combined); hint != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
}
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
@@ -286,7 +274,7 @@ func doAutoUpdate(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, pm)
|
||||
hint := verificationFailureHint(updater, latest)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false,
|
||||
@@ -316,33 +304,23 @@ func doAutoUpdate(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 {
|
||||
skillsPM := "npx"
|
||||
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
|
||||
skillsPM = "pnpm dlx"
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionHint(pmOutput, pm string) string {
|
||||
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
||||
return ""
|
||||
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"
|
||||
}
|
||||
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"
|
||||
return ""
|
||||
}
|
||||
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest 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,27 +57,6 @@ 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{}
|
||||
@@ -102,110 +81,6 @@ 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
|
||||
@@ -391,9 +266,6 @@ 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) {
|
||||
@@ -867,9 +739,9 @@ func TestPermissionHint(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
|
||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
||||
currentOS = "linux"
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
||||
if !strings.Contains(hint, "npm global prefix") {
|
||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||
}
|
||||
@@ -877,25 +749,16 @@ 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", "npm")
|
||||
hint = permissionHint("EACCES: permission denied")
|
||||
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", "npm"); got != "" {
|
||||
if got := permissionHint("some other error"); got != "" {
|
||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,28 +72,6 @@ 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/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
|
||||
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/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
|
||||
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/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
|
||||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
|
||||
"missing_scopes": []any{"calendar:event:create"},
|
||||
}
|
||||
for k, want := range wantFields {
|
||||
|
||||
@@ -77,15 +77,20 @@ 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{}
|
||||
if svc, ok := registry.SchemaCatalog().Service(service); ok {
|
||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
||||
if svc.Name != service {
|
||||
continue
|
||||
}
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||
}
|
||||
break
|
||||
}
|
||||
return func(h string) string {
|
||||
if id, ok := byForm[strings.TrimSpace(h)]; ok {
|
||||
h = strings.TrimSpace(h)
|
||||
if id, ok := byForm[h]; ok {
|
||||
return id
|
||||
}
|
||||
return headingToKey(h) // one home for the shortcut/method key convention
|
||||
return strings.ReplaceAll(h, " ", ".")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// fixtureMD is a minimal affordance source: two methods, each with a lead
|
||||
@@ -86,38 +84,3 @@ 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,7 +19,6 @@ 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`
|
||||
//
|
||||
@@ -35,56 +34,16 @@ 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 {
|
||||
h = strings.TrimSpace(h)
|
||||
if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim
|
||||
return h
|
||||
}
|
||||
return strings.ReplaceAll(h, " ", ".")
|
||||
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
|
||||
}
|
||||
|
||||
type mdSection struct {
|
||||
@@ -123,7 +82,6 @@ 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":
|
||||
@@ -134,14 +92,12 @@ 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 s := mergeSkills(skill, perCmdSkills); len(s) > 0 {
|
||||
a.Skills = s
|
||||
if skill != "" {
|
||||
a.Skills = []string{skill}
|
||||
}
|
||||
out[curKey] = a
|
||||
}
|
||||
@@ -201,7 +157,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: linkToBacktick(pending), Command: strings.Join(fence, "\n")})
|
||||
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
|
||||
pending = ""
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package cmdmeta is the single source of truth for command metadata that the
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// Three axes:
|
||||
//
|
||||
@@ -53,12 +51,6 @@ 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
|
||||
@@ -133,35 +125,6 @@ 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.
|
||||
|
||||
@@ -6,10 +6,12 @@ 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"
|
||||
@@ -38,6 +40,8 @@ const (
|
||||
BuildKindUnknown = "unknown"
|
||||
|
||||
officialModulePath = "github.com/larksuite/cli"
|
||||
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||
@@ -45,6 +49,25 @@ 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)
|
||||
@@ -52,7 +75,7 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
return h
|
||||
|
||||
@@ -6,6 +6,7 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
@@ -263,9 +264,88 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// AgentTraceValue / HeaderAgentTrace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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,7 +19,6 @@ 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"
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// 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,14 +10,12 @@ 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).
|
||||
// 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.
|
||||
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
||||
// depend on internal/core (which would create an import cycle).
|
||||
type ClassifyContext struct {
|
||||
Brand string // "feishu" | "lark" — drives console_url host
|
||||
AppID string // placed in console_url
|
||||
@@ -446,27 +444,28 @@ func extractMissingScopes(resp map[string]any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||
if appID == "" {
|
||||
return ""
|
||||
}
|
||||
// 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 base
|
||||
host := "open.feishu.cn"
|
||||
if brand == "lark" {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
|
||||
// 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)
|
||||
if len(scopes) == 0 {
|
||||
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
||||
}
|
||||
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, 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/page/scope-apply?clientID=cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", 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/page/scope-apply?clientID=cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", 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{"scopes=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"scopes=scope&evil=injected"},
|
||||
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"q=scope&evil=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in scope splits fragment",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope#fragment"},
|
||||
wantInURL: []string{"scopes=scope%23fragment"},
|
||||
denyInURL: []string{"scopes=scope#fragment"},
|
||||
wantInURL: []string{"q=scope%23fragment"},
|
||||
denyInURL: []string{"q=scope#fragment"},
|
||||
},
|
||||
{
|
||||
name: "question mark in appID prematurely opens query",
|
||||
appID: "good?q=injected",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
|
||||
denyInURL: []string{"clientID=good?q=injected"},
|
||||
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
||||
denyInURL: []string{"/app/good?q=injected/auth"},
|
||||
},
|
||||
{
|
||||
name: "hash in appID truncates URL",
|
||||
appID: "good#fragment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"clientID=good%23fragment"},
|
||||
denyInURL: []string{"clientID=good#fragment"},
|
||||
wantInURL: []string{"/app/good%23fragment/auth"},
|
||||
denyInURL: []string{"/app/good#fragment/auth"},
|
||||
},
|
||||
{
|
||||
name: "slash in appID does not open a new path segment",
|
||||
name: "slash in appID escapes path segment",
|
||||
appID: "good/extra/segment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
|
||||
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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, "/page/scope-apply?clientID=cli_a123") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", 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/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
|
||||
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=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,22 +10,20 @@ 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{
|
||||
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
|
||||
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)
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -114,35 +114,8 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
||||
{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) {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
|
||||
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
|
||||
// command-specific recovery guidance at the shortcut layer.
|
||||
var wikiCodeMeta = map[int]CodeMeta{
|
||||
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
|
||||
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
|
||||
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }
|
||||
@@ -8,11 +8,8 @@ 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 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.
|
||||
// Skills entries are skill names (or name/path) rendered as runnable
|
||||
// `lark-cli skills read <entry>` pointers.
|
||||
type Affordance struct {
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -79,15 +78,12 @@ 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(file, match) {
|
||||
if isPlaceholderCredentialURL(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) {
|
||||
@@ -134,9 +130,6 @@ func isCredentialAssignmentMatch(match string) bool {
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
}
|
||||
|
||||
@@ -291,9 +284,6 @@ func tokenLikePlaceholderValue(key, value string) bool {
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
if authCredentialTokenKey(key) {
|
||||
return false
|
||||
}
|
||||
return resourceTokenPlaceholderValue(value) ||
|
||||
maskedTokenFixturePlaceholderValue(key, normalized) ||
|
||||
isPlaceholderValue(value) ||
|
||||
@@ -323,109 +313,11 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
||||
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",
|
||||
@@ -952,7 +844,7 @@ func looksLikeEqualityComparison(value string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
||||
}
|
||||
|
||||
func isPlaceholderCredentialURL(file, raw string) bool {
|
||||
func isPlaceholderCredentialURL(raw string) bool {
|
||||
userInfo, ok := credentialURLUserInfo(raw)
|
||||
if !ok {
|
||||
return false
|
||||
@@ -961,8 +853,7 @@ func isPlaceholderCredentialURL(file, raw string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return credentialURLPasswordPlaceholder(password) ||
|
||||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
|
||||
return credentialURLPasswordPlaceholder(password)
|
||||
}
|
||||
|
||||
func credentialURLPasswordPlaceholder(password string) bool {
|
||||
@@ -976,46 +867,6 @@ 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,19 +61,6 @@ 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 {
|
||||
@@ -645,45 +632,6 @@ 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 {
|
||||
@@ -700,7 +648,6 @@ 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 {
|
||||
@@ -714,8 +661,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
|
||||
if count != 3 {
|
||||
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,68 +724,6 @@ 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 {
|
||||
@@ -1167,12 +1052,10 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
|
||||
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||
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)
|
||||
}
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ package registry
|
||||
import "github.com/larksuite/cli/internal/apicatalog"
|
||||
|
||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||
// metadata — deterministic across machines, for golden tests and schema lint.
|
||||
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
||||
// and schema lint.
|
||||
func EmbeddedCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||
}
|
||||
@@ -17,14 +18,3 @@ 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()
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// 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,7 +15,6 @@ 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
|
||||
@@ -86,9 +85,7 @@ func InitWithBrand(brand core.LarkBrand) {
|
||||
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
||||
|
||||
if !brandChanged {
|
||||
// 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) {
|
||||
if cached, err := loadCachedMerged(); err == nil {
|
||||
overlayMergedServices(cached)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// 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,11 +72,9 @@ 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: "1.0.0",
|
||||
Version: "test-1.0",
|
||||
Services: []meta.Service{
|
||||
{
|
||||
Name: name,
|
||||
@@ -162,7 +160,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCacheHit_WithinTTL(t *testing.T) {
|
||||
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||
resetInit()
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
@@ -199,7 +197,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNetworkError_SilentDegradation(t *testing.T) {
|
||||
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||
resetInit()
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
@@ -373,8 +371,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
||||
if data == nil {
|
||||
t.Fatal("expected non-nil data")
|
||||
}
|
||||
if reg.Version != "1.0.0" {
|
||||
t.Errorf("expected version 1.0.0, got %s", reg.Version)
|
||||
if reg.Version != "test-1.0" {
|
||||
t.Errorf("expected version test-1.0, got %s", reg.Version)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,9 +59,13 @@ 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(
|
||||
"%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
core.ResolveOpenBaseURL(brand),
|
||||
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
host,
|
||||
url.QueryEscape(appID),
|
||||
url.QueryEscape(scope),
|
||||
)
|
||||
|
||||
@@ -32,7 +32,6 @@ type InstallMethod int
|
||||
|
||||
const (
|
||||
InstallNpm InstallMethod = iota
|
||||
InstallPnpm
|
||||
InstallManual
|
||||
)
|
||||
|
||||
@@ -54,32 +53,22 @@ var (
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
type DetectResult struct {
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
PnpmAvailable bool
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
}
|
||||
|
||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||
func (d DetectResult) CanAutoUpdate() bool {
|
||||
switch d.Method {
|
||||
case InstallNpm:
|
||||
return d.NpmAvailable
|
||||
case InstallPnpm:
|
||||
return d.PnpmAvailable
|
||||
}
|
||||
return false
|
||||
return d.Method == InstallNpm && d.NpmAvailable
|
||||
}
|
||||
|
||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||
func (d DetectResult) ManualReason() string {
|
||||
switch {
|
||||
case d.Method == InstallNpm && !d.NpmAvailable:
|
||||
if 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 or pnpm"
|
||||
return "not installed via npm"
|
||||
}
|
||||
|
||||
// NpmResult holds the result of an npm install or skills update execution.
|
||||
@@ -103,7 +92,6 @@ 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
|
||||
@@ -113,38 +101,17 @@ 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 the
|
||||
// owning package manager is available for auto-update.
|
||||
// DetectInstallMethod determines how the CLI was installed and whether
|
||||
// npm 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}
|
||||
@@ -153,54 +120,24 @@ 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") {
|
||||
if containsPnpmMarker(resolved) {
|
||||
method = InstallPnpm
|
||||
} else {
|
||||
method = InstallNpm
|
||||
}
|
||||
method = InstallNpm
|
||||
}
|
||||
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
|
||||
npmAvailable := false
|
||||
if method == InstallNpm {
|
||||
if _, err := exec.LookPath("npm"); err == nil {
|
||||
npmAvailable = true
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
return DetectResult{
|
||||
Method: method,
|
||||
ResolvedPath: resolved,
|
||||
NpmAvailable: npmAvailable,
|
||||
}
|
||||
}
|
||||
|
||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||
@@ -226,29 +163,6 @@ 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()
|
||||
@@ -347,40 +261,19 @@ 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{}
|
||||
det := u.DetectInstallMethod()
|
||||
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
|
||||
binPath, err := exec.LookPath(launcher)
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
|
||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
|
||||
@@ -371,147 +371,3 @@ 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 = 15 * time.Second
|
||||
fetchTimeout = 5 * time.Second
|
||||
stateFile = "update-state.json"
|
||||
maxBody = 256 << 10 // 256 KB
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.66",
|
||||
"version": "1.0.63",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -215,73 +215,6 @@ 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
|
||||
@@ -304,23 +237,13 @@ 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() && 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"
|
||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/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,
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
// 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,7 +4,6 @@
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { labelDomainsForPath } = require("../domain-map");
|
||||
|
||||
// ============================================================================
|
||||
// Constants & Configuration
|
||||
@@ -36,6 +35,33 @@ 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 = {
|
||||
@@ -259,7 +285,13 @@ function skillDomainForPath(filePath) {
|
||||
|
||||
// Get business domain label based on CODEOWNERS path mapping
|
||||
function getBusinessDomain(filePath) {
|
||||
return labelDomainsForPath(filePath)[0] || "";
|
||||
const normalized = normalizePath(filePath);
|
||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function detectNewShortcutDomain(files) {
|
||||
|
||||
@@ -8,17 +8,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
script="$repo_root/scripts/resolve-changed-from.sh"
|
||||
|
||||
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
||||
|
||||
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
|
||||
trap 'rm -rf "$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("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
}, 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)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -145,10 +145,7 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
||||
existing := map[string]bool{}
|
||||
token := ""
|
||||
for {
|
||||
params := map[string]interface{}{"page_size": 100}
|
||||
if env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
||||
if token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
@@ -171,11 +168,7 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
||||
|
||||
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
||||
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||
statusParams := map[string]interface{}{}
|
||||
if env != "" {
|
||||
statusParams["env"] = env
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -215,10 +208,11 @@ 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 := dbEnvParams(rctx, map[string]interface{}{
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
"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("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
}, 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)")...),
|
||||
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(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
||||
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),
|
||||
dbEnvParams(rctx, map[string]interface{}{}),
|
||||
map[string]interface{}{"env": dbEnv(rctx)},
|
||||
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("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
}, 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)")...),
|
||||
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(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
||||
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),
|
||||
dbEnvParams(rctx, map[string]interface{}{}),
|
||||
map[string]interface{}{"env": dbEnv(rctx)},
|
||||
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("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
}, 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)")...),
|
||||
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 := dbEnvParams(rctx, map[string]interface{}{})
|
||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
||||
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("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
}, 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)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -77,9 +77,10 @@ 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 := dbEnvParams(rctx, map[string]interface{}{
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
"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("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
}, 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)")...),
|
||||
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(dbEnvParams(rctx, map[string]interface{}{
|
||||
"table": strings.TrimSpace(rctx.Str("table")),
|
||||
Params(map[string]interface{}{
|
||||
"env": dbEnv(rctx), "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,18 +95,15 @@ 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: exportQuery,
|
||||
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"))},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
||||
@@ -160,11 +157,8 @@ 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) {
|
||||
params := map[string]interface{}{"page_size": 1}
|
||||
if env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
|
||||
map[string]interface{}{"env": env, "page_size": 1}, 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("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
}, 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)")...),
|
||||
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(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
|
||||
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -100,14 +100,10 @@ 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: importQuery,
|
||||
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
|
||||
@@ -121,31 +121,6 @@ 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,16 +97,6 @@ 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})
|
||||
@@ -114,12 +104,6 @@ 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 {
|
||||
@@ -147,10 +131,6 @@ 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,10 +105,8 @@ 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, Reusable: true,
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -128,10 +126,8 @@ 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, Reusable: true,
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -323,31 +319,6 @@ 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("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
}, 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)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -291,9 +291,10 @@ func parseErrorSentinel(data string) (int, string) {
|
||||
//
|
||||
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
||||
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return dbEnvParams(rctx, map[string]interface{}{
|
||||
return map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
"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("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
}, 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)")...),
|
||||
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(dbEnvParams(rctx, map[string]interface{}{}))
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)})
|
||||
},
|
||||
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), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
|
||||
@@ -32,23 +32,19 @@ var AppsDBRecoveryDiff = common.Shortcut{
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
Flags: []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 {
|
||||
@@ -85,23 +81,19 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
Flags: []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 {
|
||||
@@ -112,7 +104,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), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false})
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
@@ -127,7 +119,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), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
@@ -165,7 +157,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), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true})
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
|
||||
if err != nil {
|
||||
return nil, withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
@@ -175,7 +167,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), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil)
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||
@@ -203,13 +195,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{})
|
||||
raw := make([]recoveryChange, 0, len(arr))
|
||||
changes := make([]recoveryChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
raw = append(raw, recoveryChange{
|
||||
changes = append(changes, recoveryChange{
|
||||
Table: common.GetString(m, "table"),
|
||||
Inserted: m["inserted"],
|
||||
Deleted: m["deleted"],
|
||||
@@ -217,33 +209,16 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
|
||||
DroppedAt: common.GetString(m, "dropped_at"),
|
||||
})
|
||||
}
|
||||
// 服务端可能对同一张表既下发 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
|
||||
tablesAffected := intFromAny(preview["tables_affected"])
|
||||
if tablesAffected == 0 {
|
||||
tablesAffected = len(changes)
|
||||
}
|
||||
est := intFromAny(preview["estimated_seconds"])
|
||||
if est == 0 {
|
||||
est = 30 // PRD 兜底
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"target": target, "tables_affected": len(seen),
|
||||
"target": target, "tables_affected": tablesAffected,
|
||||
"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("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
}, 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)")...),
|
||||
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 := dbEnvParams(rctx, map[string]interface{}{})
|
||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
||||
if rctx.Format == "pretty" {
|
||||
params["format"] = "ddl"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -43,7 +42,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("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
}, 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)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -111,9 +110,10 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
|
||||
}
|
||||
|
||||
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
})
|
||||
}
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
@@ -286,17 +286,6 @@ 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,11 +236,7 @@ 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},
|
||||
{"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},
|
||||
{"unsupported string", "x", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
||||
@@ -34,16 +34,6 @@ 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") {
|
||||
|
||||
@@ -67,26 +67,6 @@ 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",
|
||||
@@ -245,7 +225,6 @@ 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)),
|
||||
@@ -306,9 +285,6 @@ 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{}
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
// 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,76 +673,6 @@ 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
|
||||
@@ -2304,17 +2234,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
||||
// Shortcuts() registration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShortcuts_Returns10(t *testing.T) {
|
||||
func TestShortcuts_Returns9(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 10 {
|
||||
t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
|
||||
if len(shortcuts) != 9 {
|
||||
t.Fatalf("expected 9 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", "+get"} {
|
||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing shortcut %s", want)
|
||||
}
|
||||
@@ -3178,193 +3108,3 @@ 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,6 +17,5 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarSuggestion,
|
||||
CalendarMeeting,
|
||||
CalendarSearchEvent,
|
||||
CalendarGet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,7 +889,6 @@ 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,10 +150,12 @@ 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,张三'",
|
||||
"on has_more=true add filters or tighten --query — there is no auto-pagination.",
|
||||
"open_id is the stable identifier for follow-up commands; 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 | 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: "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: "doc-id", Desc: "document ID (for drive_route_token)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
// 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
|
||||
},
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestDocsHistoryValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
param string
|
||||
category errs.Category
|
||||
subtype errs.Subtype
|
||||
wantCause bool
|
||||
}{
|
||||
{
|
||||
name: "list rejects legacy doc URL",
|
||||
shortcut: DocsHistoryList,
|
||||
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
|
||||
param: "--doc",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "list rejects invalid page size",
|
||||
shortcut: DocsHistoryList,
|
||||
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
|
||||
param: "--page-size",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-numeric history version id",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
wantCause: true,
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-positive history version id",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "revert rejects invalid wait timeout",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
|
||||
param: "--wait-timeout-ms",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "status rejects empty task id",
|
||||
shortcut: DocsHistoryRevertStatus,
|
||||
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
|
||||
param: "--task-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
|
||||
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != tt.category {
|
||||
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
|
||||
}
|
||||
if problem.Subtype != tt.subtype {
|
||||
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.param {
|
||||
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
|
||||
}
|
||||
if tt.wantCause && errors.Unwrap(err) == nil {
|
||||
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"page-size": "5",
|
||||
"page-token": "page_token_1",
|
||||
})
|
||||
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
|
||||
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
|
||||
t.Fatalf("list dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
|
||||
t.Fatalf("list page_size = %d, want 5", got)
|
||||
}
|
||||
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
|
||||
t.Fatalf("list page_token = %#v, want page_token_1", got)
|
||||
}
|
||||
|
||||
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"history-version-id": "42",
|
||||
"wait-timeout-ms": "30000",
|
||||
})
|
||||
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
|
||||
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
|
||||
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("revert history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
|
||||
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
|
||||
}
|
||||
|
||||
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"task-id": "task_1",
|
||||
})
|
||||
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
|
||||
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
|
||||
t.Fatalf("status dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
|
||||
t.Fatalf("status task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteList(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"entries": []interface{}{
|
||||
map[string]interface{}{
|
||||
"revision_id": float64(42),
|
||||
"history_version_id": "11",
|
||||
"edit_time": "1780000000",
|
||||
"type": float64(1),
|
||||
"editor_ids": []interface{}{"ou_1"},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "page_token_2",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryList, []string{
|
||||
"+history-list",
|
||||
"--doc", "doxcnHistory",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_token_1",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["page_token"]; got != "page_token_2" {
|
||||
t.Fatalf("page_token = %#v, want page_token_2", got)
|
||||
}
|
||||
entries, _ := data["entries"].([]interface{})
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries = %#v, want one entry", data["entries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteRevert(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"task_id": "task_1",
|
||||
"status": "running",
|
||||
"history_version_id": "42",
|
||||
"poll_after_ms": float64(10000),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
|
||||
"+history-revert",
|
||||
"--doc", "doxcnHistory",
|
||||
"--history-version-id", "42",
|
||||
"--wait-timeout-ms", "0",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
|
||||
}
|
||||
if got := body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
|
||||
t.Fatalf("wait_timeout_ms = %d, want 0", got)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["task_id"]; got != "task_1" {
|
||||
t.Fatalf("task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"status": "partial_failed",
|
||||
"history_version_id": "11",
|
||||
"failed_block_tokens": []interface{}{"blk_1"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
|
||||
"+history-revert-status",
|
||||
"--doc", "doxcnHistory",
|
||||
"--task-id", "task_1",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["status"]; got != "partial_failed" {
|
||||
t.Fatalf("status = %#v, want partial_failed", got)
|
||||
}
|
||||
if got := data["history_version_id"]; got != "11" {
|
||||
t.Fatalf("history_version_id = %#v, want 11", got)
|
||||
}
|
||||
failed, _ := data["failed_block_tokens"].([]interface{})
|
||||
if len(failed) != 1 || failed[0] != "blk_1" {
|
||||
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
for _, flag := range shortcut.Flags {
|
||||
switch flag.Type {
|
||||
case "int":
|
||||
cmd.Flags().Int(flag.Name, 0, flag.Desc)
|
||||
default:
|
||||
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
|
||||
}
|
||||
}
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestDocsHistoryURLValidationMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "only supports docx documents") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -27,17 +27,12 @@ const (
|
||||
html5BlockDataAttr = "data"
|
||||
html5BlockReferenceRoot = "doc-fetch-resources"
|
||||
html5BlockReferenceMaxRaw = 1024
|
||||
|
||||
whiteboardTag = "whiteboard"
|
||||
whiteboardTypeAttr = "type"
|
||||
whiteboardPathAttr = "path"
|
||||
)
|
||||
|
||||
var (
|
||||
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
|
||||
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
|
||||
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*(?:/>|>.*?</whiteboard>)`)
|
||||
)
|
||||
|
||||
type html5BlockReferenceEntry struct {
|
||||
@@ -63,11 +58,6 @@ type html5BlockStartTag struct {
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
type whiteboardStartTag struct {
|
||||
Attrs []html5BlockAttr
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildCreateBody(runtime)
|
||||
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
|
||||
@@ -125,11 +115,7 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
|
||||
content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap)
|
||||
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
@@ -246,248 +232,6 @@ func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string
|
||||
return out, compactReferenceMap(refMap), nil
|
||||
}
|
||||
|
||||
func prepareWhiteboardWriteContent(runtime *common.RuntimeContext, format string, content string) (string, error) {
|
||||
if !strings.Contains(content, "<whiteboard") {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
rewrite := func(segment string) (string, error) {
|
||||
return rewriteWhiteboardFileRefs(runtime, segment)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return rewrite(content)
|
||||
}
|
||||
|
||||
var rewriteErrs []error
|
||||
out := applyOutsideCodeFences(content, func(segment string) string {
|
||||
outSegment, rewriteErr := rewrite(segment)
|
||||
if rewriteErr != nil {
|
||||
rewriteErrs = append(rewriteErrs, rewriteErr)
|
||||
return segment
|
||||
}
|
||||
return outSegment
|
||||
})
|
||||
if len(rewriteErrs) > 0 {
|
||||
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rewriteWhiteboardFileRefs(runtime *common.RuntimeContext, content string) (string, error) {
|
||||
var rewriteErrs []error
|
||||
out := whiteboardElementPattern.ReplaceAllStringFunc(content, func(raw string) string {
|
||||
rewritten, err := rewriteWhiteboardFileRef(runtime, raw)
|
||||
if err != nil {
|
||||
rewriteErrs = append(rewriteErrs, err)
|
||||
return raw
|
||||
}
|
||||
return rewritten
|
||||
})
|
||||
if len(rewriteErrs) > 0 {
|
||||
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rewriteWhiteboardFileRef(runtime *common.RuntimeContext, raw string) (string, error) {
|
||||
startRaw, body, _, ok := splitWhiteboardElement(raw)
|
||||
if !ok {
|
||||
return raw, nil
|
||||
}
|
||||
tag, err := parseWhiteboardStartTag(startRaw)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("invalid whiteboard tag: %v", err).WithParam("whiteboard")
|
||||
}
|
||||
|
||||
pathValue, hasPath := tag.attr(whiteboardPathAttr)
|
||||
bodyPath, hasBodyPath := whiteboardBodyPathRef(body)
|
||||
if !hasPath && !hasBodyPath {
|
||||
return raw, nil
|
||||
}
|
||||
if hasPath && strings.TrimSpace(body) != "" {
|
||||
return "", common.ValidationErrorf("whiteboard cannot contain both path and inline content").WithParam("whiteboard")
|
||||
}
|
||||
if hasPath && hasBodyPath {
|
||||
return "", common.ValidationErrorf("whiteboard cannot contain both path and @file body").WithParam("whiteboard")
|
||||
}
|
||||
|
||||
typRaw, ok := tag.attr(whiteboardTypeAttr)
|
||||
if !ok || strings.TrimSpace(typRaw) == "" {
|
||||
return "", common.ValidationErrorf("whiteboard file input requires type=\"svg\", type=\"mermaid\", or type=\"plantuml\"").WithParam("type")
|
||||
}
|
||||
typ, ok := canonicalWhiteboardFileType(typRaw)
|
||||
if !ok {
|
||||
return "", common.ValidationErrorf("whiteboard file input only supports type=\"svg\", type=\"mermaid\", or type=\"plantuml\", got %q", typRaw).WithParam("type")
|
||||
}
|
||||
|
||||
if hasBodyPath {
|
||||
pathValue = bodyPath
|
||||
}
|
||||
data, err := readWhiteboardPath(runtime, pathValue, typ)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tag.setAttr(whiteboardTypeAttr, typ)
|
||||
tag.removeAttrs(whiteboardPathAttr)
|
||||
return tag.render(false) + whiteboardContentForType(typ, data) + "</" + whiteboardTag + ">", nil
|
||||
}
|
||||
|
||||
func splitWhiteboardElement(raw string) (startTag string, body string, selfClosing bool, ok bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
selfClosing = strings.HasSuffix(trimmed, "/>")
|
||||
if selfClosing {
|
||||
return raw, "", true, true
|
||||
}
|
||||
startEnd := strings.Index(raw, ">")
|
||||
if startEnd < 0 {
|
||||
return "", "", false, false
|
||||
}
|
||||
endStart := strings.LastIndex(strings.ToLower(raw), "</whiteboard>")
|
||||
if endStart < 0 || endStart < startEnd {
|
||||
return "", "", false, false
|
||||
}
|
||||
return raw[:startEnd+1], raw[startEnd+1 : endStart], false, true
|
||||
}
|
||||
|
||||
func whiteboardBodyPathRef(body string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if !strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "@@") {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsAny(trimmed, "\r\n") {
|
||||
return "", false
|
||||
}
|
||||
return trimmed, true
|
||||
}
|
||||
|
||||
func canonicalWhiteboardFileType(raw string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "svg":
|
||||
return "svg", true
|
||||
case "mermaid":
|
||||
return "mermaid", true
|
||||
case "plantuml":
|
||||
return "plantuml", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func readWhiteboardPath(runtime *common.RuntimeContext, pathValue string, typ string) (string, error) {
|
||||
pathRaw := strings.TrimSpace(pathValue)
|
||||
if !strings.HasPrefix(pathRaw, "@") {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must start with @, for example @diagram.%s", typ, pathValue, exampleWhiteboardExt(typ)).WithParam("path")
|
||||
}
|
||||
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
|
||||
if relPath == "" {
|
||||
return "", common.ValidationErrorf("whiteboard %s path cannot be empty after @", typ).WithParam("path")
|
||||
}
|
||||
clean := filepath.Clean(relPath)
|
||||
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must be a relative path within the current working directory", typ, pathValue).WithParam("path")
|
||||
}
|
||||
if !whiteboardExtAllowed(typ, strings.ToLower(filepath.Ext(clean))) {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must point to a %s file", typ, pathValue, whiteboardExtList(typ)).WithParam("path")
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", typ, clean, err).
|
||||
WithParam("path").
|
||||
WithParams(errs.InvalidParam{Name: clean, Reason: fmt.Sprintf("whiteboard %s path cannot be read", typ)}).
|
||||
WithCause(err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func whiteboardExtAllowed(typ string, ext string) bool {
|
||||
for _, allowed := range whiteboardAllowedExts(typ) {
|
||||
if ext == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func whiteboardAllowedExts(typ string) []string {
|
||||
switch typ {
|
||||
case "svg":
|
||||
return []string{".svg"}
|
||||
case "mermaid":
|
||||
return []string{".mermaid", ".mmd"}
|
||||
case "plantuml":
|
||||
return []string{".plantuml", ".puml", ".pu", ".uml"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func whiteboardExtList(typ string) string {
|
||||
return strings.Join(whiteboardAllowedExts(typ), ", ")
|
||||
}
|
||||
|
||||
func exampleWhiteboardExt(typ string) string {
|
||||
exts := whiteboardAllowedExts(typ)
|
||||
if len(exts) == 0 {
|
||||
return "txt"
|
||||
}
|
||||
return strings.TrimPrefix(exts[0], ".")
|
||||
}
|
||||
|
||||
func whiteboardContentForType(typ string, data string) string {
|
||||
if typ == "svg" {
|
||||
return data
|
||||
}
|
||||
return escapeXMLText(data)
|
||||
}
|
||||
|
||||
func aggregateWhiteboardRewriteErrors(rewriteErrs []error) error {
|
||||
flatErrs := flattenWhiteboardRewriteErrors(rewriteErrs)
|
||||
messages := make([]string, 0, len(flatErrs))
|
||||
params := make([]errs.InvalidParam, 0, len(flatErrs))
|
||||
for _, err := range flatErrs {
|
||||
messages = append(messages, err.Error())
|
||||
params = append(params, whiteboardInvalidParamsFromError(err)...)
|
||||
}
|
||||
validationErr := common.ValidationErrorf("whiteboard file input failed: %s", strings.Join(messages, "; ")).
|
||||
WithParam("whiteboard").
|
||||
WithCause(errors.Join(flatErrs...))
|
||||
if len(params) > 0 {
|
||||
validationErr.WithParams(params...)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func flattenWhiteboardRewriteErrors(rewriteErrs []error) []error {
|
||||
flatErrs := make([]error, 0, len(rewriteErrs))
|
||||
for _, err := range rewriteErrs {
|
||||
var validationErr *errs.ValidationError
|
||||
if errors.As(err, &validationErr) && validationErr.Param == "whiteboard" && validationErr.Cause != nil {
|
||||
if joined, ok := validationErr.Cause.(interface{ Unwrap() []error }); ok {
|
||||
flatErrs = append(flatErrs, flattenWhiteboardRewriteErrors(joined.Unwrap())...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
flatErrs = append(flatErrs, err)
|
||||
}
|
||||
return flatErrs
|
||||
}
|
||||
|
||||
func whiteboardInvalidParamsFromError(err error) []errs.InvalidParam {
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
return nil
|
||||
}
|
||||
if len(validationErr.Params) > 0 {
|
||||
return validationErr.Params
|
||||
}
|
||||
if validationErr.Param != "" {
|
||||
return []errs.InvalidParam{{Name: validationErr.Param, Reason: validationErr.Message}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHTML5BlockWriteElementBodies(format string, content string) error {
|
||||
validateSegment := func(segment string) error {
|
||||
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
|
||||
@@ -877,34 +621,6 @@ func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
|
||||
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
|
||||
func parseWhiteboardStartTag(raw string) (whiteboardStartTag, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
selfClosing := strings.HasSuffix(trimmed, "/>")
|
||||
decoder := xml.NewDecoder(strings.NewReader(raw))
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return whiteboardStartTag{}, err
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if start.Name.Local != whiteboardTag {
|
||||
return whiteboardStartTag{}, fmt.Errorf("expected <%s>, got <%s>", whiteboardTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
attrs := make([]html5BlockAttr, 0, len(start.Attr))
|
||||
for _, attr := range start.Attr {
|
||||
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
|
||||
}
|
||||
return whiteboardStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
|
||||
}
|
||||
return whiteboardStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||
for _, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
@@ -914,15 +630,6 @@ func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (t whiteboardStartTag) attr(name string) (string, bool) {
|
||||
for _, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
return attr.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) hasAttr(name string) bool {
|
||||
_, ok := t.attr(name)
|
||||
return ok
|
||||
@@ -943,31 +650,6 @@ func (t *html5BlockStartTag) removeAttrs(names ...string) {
|
||||
t.Attrs = attrs
|
||||
}
|
||||
|
||||
func (t *whiteboardStartTag) removeAttrs(names ...string) {
|
||||
remove := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
remove[name] = struct{}{}
|
||||
}
|
||||
attrs := t.Attrs[:0]
|
||||
for _, attr := range t.Attrs {
|
||||
if _, ok := remove[attr.Name]; ok {
|
||||
continue
|
||||
}
|
||||
attrs = append(attrs, attr)
|
||||
}
|
||||
t.Attrs = attrs
|
||||
}
|
||||
|
||||
func (t *whiteboardStartTag) setAttr(name string, value string) {
|
||||
for i, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
t.Attrs[i].Value = value
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value})
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('<')
|
||||
@@ -992,25 +674,6 @@ func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (t whiteboardStartTag) render(selfClosing bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('<')
|
||||
b.WriteString(whiteboardTag)
|
||||
for _, attr := range t.Attrs {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(attr.Name)
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(escapeXMLAttr(attr.Value))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
if selfClosing {
|
||||
b.WriteString("/>")
|
||||
} else {
|
||||
b.WriteByte('>')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLAttr(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
@@ -1031,18 +694,3 @@ func escapeXMLAttr(value string) string {
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLText(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
b.WriteString("&")
|
||||
case '<':
|
||||
b.WriteString("<")
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -6,13 +6,11 @@ package doc
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -118,61 +116,6 @@ func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
files := map[string]string{
|
||||
"diagram.svg": `<svg viewBox="0 0 10 10"><text>A</text></svg>`,
|
||||
"flow.mmd": "flowchart TD\nA --> B",
|
||||
"sequence.puml": "@startuml\nAlice -> Bob: hi\n@enduml",
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@diagram.svg"></whiteboard>`,
|
||||
`<whiteboard type="mermaid">@flow.mmd</whiteboard>`,
|
||||
`<whiteboard type="plantUML" path="@sequence.puml"/>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
got := body["content"].(string)
|
||||
for _, want := range []string{
|
||||
`<whiteboard type="svg"><svg viewBox="0 0 10 10"><text>A</text></svg></whiteboard>`,
|
||||
"<whiteboard type=\"mermaid\">flowchart TD\nA --> B</whiteboard>",
|
||||
"<whiteboard type=\"plantuml\">@startuml\nAlice -> Bob: hi\n@enduml</whiteboard>",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("content missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `path="@`) {
|
||||
t.Fatalf("content still contains whiteboard path attr: %s", got)
|
||||
}
|
||||
if _, ok := body["reference_map"]; ok {
|
||||
t.Fatalf("whiteboard file input must not create reference_map: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name {
|
||||
@@ -464,119 +407,6 @@ func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputReportsAllMissingPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@missing.svg"></whiteboard>`,
|
||||
`<whiteboard type="mermaid">@missing.mmd</whiteboard>`,
|
||||
`<whiteboard type="plantuml" path="@missing.puml"></whiteboard>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected aggregated whiteboard path error")
|
||||
}
|
||||
assertWhiteboardFileInputValidation(t, err, []string{
|
||||
"missing.svg",
|
||||
"missing.mmd",
|
||||
"missing.puml",
|
||||
}, []string{
|
||||
`whiteboard svg path "missing.svg" cannot be read`,
|
||||
`whiteboard mermaid path "missing.mmd" cannot be read`,
|
||||
`whiteboard plantuml path "missing.puml" cannot be read`,
|
||||
})
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputMarkdownReportsMissingPathsAcrossFences(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--doc-format", "markdown",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@before.svg"></whiteboard>`,
|
||||
"```",
|
||||
`<whiteboard type="svg" path="@inside.svg"></whiteboard>`,
|
||||
"```",
|
||||
`<whiteboard type="plantuml" path="@after.puml"></whiteboard>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected aggregated whiteboard path error")
|
||||
}
|
||||
assertWhiteboardFileInputValidation(t, err, []string{
|
||||
"before.svg",
|
||||
"after.puml",
|
||||
}, []string{
|
||||
`whiteboard svg path "before.svg" cannot be read`,
|
||||
`whiteboard plantuml path "after.puml" cannot be read`,
|
||||
})
|
||||
if strings.Contains(err.Error(), "inside.svg") {
|
||||
t.Fatalf("error should ignore fenced whiteboard path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWhiteboardFileInputValidation(t *testing.T, err error, wantParams []string, wantMessages []string) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("category/subtype = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "whiteboard" {
|
||||
t.Fatalf("param = %q, want whiteboard", validationErr.Param)
|
||||
}
|
||||
if validationErr.Cause == nil {
|
||||
t.Fatal("expected aggregated error to preserve cause")
|
||||
}
|
||||
var childValidationErr *errs.ValidationError
|
||||
if !errors.As(validationErr.Cause, &childValidationErr) || childValidationErr.Cause == nil {
|
||||
t.Fatalf("expected child validation cause to preserve file read cause, got %#v", validationErr.Cause)
|
||||
}
|
||||
|
||||
gotParams := make(map[string]string, len(validationErr.Params))
|
||||
for _, param := range validationErr.Params {
|
||||
gotParams[param.Name] = param.Reason
|
||||
}
|
||||
if len(gotParams) != len(wantParams) {
|
||||
t.Fatalf("params = %#v, want names %v", validationErr.Params, wantParams)
|
||||
}
|
||||
for _, param := range wantParams {
|
||||
reason, ok := gotParams[param]
|
||||
if !ok {
|
||||
t.Fatalf("params = %#v, want name %q", validationErr.Params, param)
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatalf("param %q missing reason: %#v", param, validationErr.Params)
|
||||
}
|
||||
}
|
||||
for _, want := range wantMessages {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q:\n%v", want, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Cause.Error(), want) {
|
||||
t.Fatalf("cause missing %q:\n%v", want, validationErr.Cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
@@ -31,8 +31,6 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
|
||||
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
||||
case "update":
|
||||
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||
case "history-list", "history-revert", "history-revert-status":
|
||||
return docsSkillReadCommand + " references/lark-doc-history.md"
|
||||
default:
|
||||
return docsSkillReadCommand
|
||||
}
|
||||
@@ -46,12 +44,6 @@ func docsHelpCommandForShortcut(shortcut string) string {
|
||||
return "lark-cli docs +fetch --help"
|
||||
case "update":
|
||||
return "lark-cli docs +update --help"
|
||||
case "history-list":
|
||||
return "lark-cli docs +history-list --help"
|
||||
case "history-revert":
|
||||
return "lark-cli docs +history-revert --help"
|
||||
case "history-revert-status":
|
||||
return "lark-cli docs +history-revert-status --help"
|
||||
default:
|
||||
return "lark-cli docs --help"
|
||||
}
|
||||
@@ -64,9 +56,6 @@ func Shortcuts() []common.Shortcut {
|
||||
DocsCreate,
|
||||
DocsFetch,
|
||||
DocsUpdate,
|
||||
DocsHistoryList,
|
||||
DocsHistoryRevert,
|
||||
DocsHistoryRevertStatus,
|
||||
DocMediaInsert,
|
||||
DocMediaUpload,
|
||||
DocMediaPreview,
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -29,8 +28,6 @@ const (
|
||||
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
|
||||
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
||||
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
|
||||
|
||||
driveImportConcurrentOperationHint = "This import conflict means another operation is running in the same Drive location. Run batch imports to the same folder/root or target bitable serially. Wait a few seconds before retrying each failed import; retry each failed item at most 3 times, then stop and report the conflict."
|
||||
)
|
||||
|
||||
// driveImportExtToDocTypes defines which source file extensions can be imported
|
||||
@@ -50,8 +47,6 @@ var driveImportExtToDocTypes = map[string][]string{
|
||||
"pptx": {"slides"},
|
||||
}
|
||||
|
||||
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
|
||||
|
||||
// driveImportSpec contains the user-facing import inputs after normalization.
|
||||
type driveImportSpec struct {
|
||||
FilePath string
|
||||
@@ -432,7 +427,11 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, driveImportFailureError(status)
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
||||
}
|
||||
}
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
@@ -441,40 +440,3 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
|
||||
func driveImportFailureError(status driveImportStatus) *errs.APIError {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
|
||||
apiErr := errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
||||
if code, ok := driveImportConcurrentOperationCode(msg); ok {
|
||||
apiErr = apiErr.WithCode(code).WithRetryable().WithHint(driveImportConcurrentOperationHint)
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
func driveImportConcurrentOperationCode(msg string) (int, bool) {
|
||||
for _, code := range driveImportConcurrentOperationCodes {
|
||||
codeText := strconv.Itoa(code)
|
||||
for idx := strings.Index(msg, codeText); idx >= 0; {
|
||||
end := idx + len(codeText)
|
||||
if (idx == 0 || !isASCIIDigit(msg[idx-1])) && (end == len(msg) || !isASCIIDigit(msg[end])) {
|
||||
return code, true
|
||||
}
|
||||
|
||||
nextStart := idx + 1
|
||||
next := strings.Index(msg[nextStart:], codeText)
|
||||
if next < 0 {
|
||||
break
|
||||
}
|
||||
idx = nextStart + next
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isASCIIDigit(ch byte) bool {
|
||||
return ch >= '0' && ch <= '9'
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -212,82 +211,6 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportFailureErrorAddsConcurrentOperationGuidance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, code := range driveImportConcurrentOperationCodes {
|
||||
t.Run(strconv.Itoa(code), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := driveImportFailureError(driveImportStatus{
|
||||
JobStatus: 3,
|
||||
JobErrorMsg: "call CreateObjNode return error code, code: " + strconv.Itoa(code) + ", message:",
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeServerError)
|
||||
}
|
||||
if problem.Code != code {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, code)
|
||||
}
|
||||
if !problem.Retryable {
|
||||
t.Fatal("expected retryable error")
|
||||
}
|
||||
if problem.Hint != driveImportConcurrentOperationHint {
|
||||
t.Fatalf("hint = %q, want %q", problem.Hint, driveImportConcurrentOperationHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportFailureErrorLeavesOtherFailuresUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
msg string
|
||||
}{
|
||||
{
|
||||
name: "ordinary failure",
|
||||
msg: "unsupported conversion",
|
||||
},
|
||||
{
|
||||
name: "longer numeric code containing known code",
|
||||
msg: "call CreateObjNode return error code, code: 12321401012, message:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := driveImportFailureError(driveImportStatus{
|
||||
JobStatus: 3,
|
||||
JobErrorMsg: tt.msg,
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Code != 0 {
|
||||
t.Fatalf("code = %d, want 0", problem.Code)
|
||||
}
|
||||
if problem.Retryable {
|
||||
t.Fatal("expected non-concurrency failure to remain non-retryable")
|
||||
}
|
||||
if problem.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", problem.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -184,7 +184,6 @@ var DrivePull = common.Shortcut{
|
||||
|
||||
var downloaded, skipped, failed, deletedLocal int
|
||||
downloadFailed := 0
|
||||
aborted := false
|
||||
items := make([]drivePullItem, 0)
|
||||
|
||||
// Deterministic iteration order for output stability.
|
||||
@@ -195,7 +194,7 @@ var DrivePull = common.Shortcut{
|
||||
sort.Strings(downloadablePaths)
|
||||
|
||||
for _, rel := range downloadablePaths {
|
||||
if aborted {
|
||||
if drivePullHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
targetFile := remoteFiles[rel]
|
||||
@@ -233,7 +232,6 @@ var DrivePull = common.Shortcut{
|
||||
failed++
|
||||
downloadFailed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -300,7 +298,7 @@ var DrivePull = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_local": deletedLocal,
|
||||
"aborted": aborted,
|
||||
"aborted": drivePullHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -349,6 +347,15 @@ func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func drivePullHasTerminalFailure(items []drivePullItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// drivePullDownload streams one Drive file into the local mirror target and
|
||||
// then best-effort aligns the local mtime to Drive's modified_time.
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
|
||||
|
||||
@@ -35,7 +35,6 @@ type drivePushItem struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
ErrorClass string `json:"error_class,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
@@ -49,7 +48,6 @@ type driveBatchFailureDecision struct {
|
||||
Subtype string
|
||||
Retryable bool
|
||||
Terminal bool
|
||||
Hint string
|
||||
}
|
||||
|
||||
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
||||
@@ -242,7 +240,6 @@ var DrivePush = common.Shortcut{
|
||||
// locally and now on Drive too), which is the worst-of-both-worlds
|
||||
// outcome the review flagged.
|
||||
uploadFailed := false
|
||||
aborted := false
|
||||
|
||||
// folderCache holds rel_path → folder_token. Seeded from the remote
|
||||
// listing (so we don't recreate folders that already exist) and
|
||||
@@ -269,7 +266,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
@@ -288,7 +284,7 @@ var DrivePush = common.Shortcut{
|
||||
|
||||
for _, rel := range localPaths {
|
||||
localFile := localFiles[rel]
|
||||
if uploadFailed && aborted {
|
||||
if uploadFailed && drivePushHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -305,7 +301,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||
break
|
||||
}
|
||||
@@ -337,7 +332,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -356,7 +350,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
@@ -369,7 +362,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -415,15 +407,10 @@ var DrivePush = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||
if drivePushIsAlreadyDeleted(err) {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "already_deleted"})
|
||||
continue
|
||||
}
|
||||
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
||||
abortDelete = true
|
||||
break
|
||||
@@ -442,7 +429,7 @@ var DrivePush = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_remote": deletedRemote,
|
||||
"aborted": aborted,
|
||||
"aborted": drivePushHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -580,7 +567,6 @@ func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int
|
||||
Action: action,
|
||||
SizeBytes: sizeBytes,
|
||||
Error: err.Error(),
|
||||
Hint: decision.Hint,
|
||||
Phase: phase,
|
||||
ErrorClass: decision.Class,
|
||||
Code: decision.Code,
|
||||
@@ -627,10 +613,6 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
decision.Class = "file_size_limit"
|
||||
case problem.Code == 1062009:
|
||||
decision.Class = "upload_size_mismatch"
|
||||
case problem.Code == 1061044:
|
||||
decision.Class = "parent_node_missing"
|
||||
decision.Terminal = true
|
||||
decision.Hint = "The destination parent folder no longer exists or is not visible. Verify --folder-token, folder permissions, and whether a parent directory was deleted during push before retrying."
|
||||
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
|
||||
decision.Class = "remote_not_found"
|
||||
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
||||
@@ -644,9 +626,22 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
return decision
|
||||
}
|
||||
|
||||
func drivePushIsAlreadyDeleted(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
return ok && problem.Code == 1061007
|
||||
func drivePushHasTerminalFailure(items []drivePushItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveTerminalBatchErrorClass(errorClass string) bool {
|
||||
switch errorClass {
|
||||
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||
|
||||
@@ -732,65 +732,6 @@ func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDeleteRemoteTreatsAlreadyDeletedAsNoop(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/tok_orphan",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061007,
|
||||
"msg": "file has been delete.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("already-deleted remote should be an idempotent success, got: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(0) {
|
||||
t.Fatalf("summary.failed = %v, want 0", got)
|
||||
}
|
||||
if got := summary["deleted_remote"]; got != float64(0) {
|
||||
t.Fatalf("summary.deleted_remote = %v, want 0 because CLI did not delete it in this run", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["action"] != "already_deleted" || item["file_token"] != "tok_orphan" {
|
||||
t.Fatalf("unexpected already-deleted item: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
@@ -1196,78 +1137,6 @@ func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterUploadParentNodeMissing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile b: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061044,
|
||||
"msg": "parent node not exist.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "parent_node_missing" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(1061044) || item["subtype"] != "not_found" || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "parent") {
|
||||
t.Fatalf("hint should point at the destination parent folder, got item=%#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["rel_path"] == "b.txt" {
|
||||
t.Fatalf("parent-node missing must abort before b.txt, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
|
||||
|
||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
||||
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||
|
||||
@@ -268,7 +268,6 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// --- Phase 2: Execute sync operations ---
|
||||
var pulled, pushed, skipped, failed int
|
||||
aborted := false
|
||||
items := make([]driveSyncItem, 0)
|
||||
|
||||
// Build push infrastructure: local walk for push + remote views + folder cache.
|
||||
@@ -287,21 +286,16 @@ var DriveSync = common.Shortcut{
|
||||
// Mirror local directory structure first (same as +push), so
|
||||
// empty local directories are not silently dropped.
|
||||
for _, relDir := range localDirs {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
item, terminal := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
|
||||
@@ -310,7 +304,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2a. Pull new_remote files.
|
||||
for _, entry := range newRemote {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||
@@ -324,7 +318,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -336,7 +329,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2b. Push new_local files.
|
||||
for _, entry := range newLocal {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
localFile, ok := pushLocalFiles[entry.RelPath]
|
||||
@@ -348,14 +341,9 @@ var DriveSync = common.Shortcut{
|
||||
parentRel := drivePushParentRel(entry.RelPath)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||
@@ -364,7 +352,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -376,7 +363,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2c. Resolve modified files by --on-conflict strategy.
|
||||
for _, entry := range modified {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
remoteFile := remoteFiles[entry.RelPath]
|
||||
@@ -410,7 +397,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -429,14 +415,9 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
||||
if parentErr != nil {
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
|
||||
@@ -454,7 +435,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -523,7 +503,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
|
||||
break
|
||||
}
|
||||
@@ -552,7 +531,7 @@ var DriveSync = common.Shortcut{
|
||||
"pushed": pushed,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"aborted": aborted,
|
||||
"aborted": driveSyncHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -598,6 +577,15 @@ func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, er
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func driveSyncHasTerminalFailure(items []driveSyncItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// driveSyncAskConflict prompts the user for a conflict resolution strategy
|
||||
// for a single file. Returns the strategy string, or empty string if the
|
||||
// user chose to skip.
|
||||
|
||||
@@ -651,7 +651,6 @@ func TestShortcuts(t *testing.T) {
|
||||
want := []string{
|
||||
"+chat-create",
|
||||
"+chat-list",
|
||||
"+chat-members-list",
|
||||
"+chat-messages-list",
|
||||
"+chat-search",
|
||||
"+chat-update",
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list"
|
||||
chatMembersListDefaultPageSize = 20
|
||||
chatMembersListMaxPageSize = 100
|
||||
// chatMembersListDefaultPageDelay throttles --page-all the same way the
|
||||
// generic paginateLoop does (200ms). It matters for tenants WITHOUT the
|
||||
// server-side member cap, where a large group drains many pages back to
|
||||
// back and could otherwise trip rate limits.
|
||||
chatMembersListDefaultPageDelay = 200
|
||||
)
|
||||
|
||||
// ImChatMembersList is the +chat-members-list shortcut: it lists chat members,
|
||||
// returning users and bots in separate buckets (users[]/bots[]). It owns its
|
||||
// pagination loop (mirroring the generic paginateLoop conventions: a per-page
|
||||
// log line, a --page-limit cap, a non-advancing-token guard) precisely because
|
||||
// the response is multi-bucket — the generic --page-all merger is built for
|
||||
// single-array responses and would drop the bots[] bucket and the final-page
|
||||
// truncations[] signal. See mergeChatMemberPages for the merge semantics.
|
||||
var ImChatMembersList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-members-list",
|
||||
Description: "List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket",
|
||||
Risk: "read",
|
||||
// Declare the narrowest scope the API accepts so tokens carrying only
|
||||
// im:chat.members:read are honored (same rationale as +chat-list).
|
||||
Scopes: []string{"im:chat.members:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"},
|
||||
{Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"},
|
||||
{Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)},
|
||||
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
|
||||
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
|
||||
{Name: "page-delay", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageDelay), Desc: "delay in ms between pages when --page-all (0 = no delay)"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Default fetches a single page; pass --page-all to walk every page.",
|
||||
"With --page-all and no explicit --page-size, the max page size is used to minimize round-trips.",
|
||||
"truncations[] in the result means the server capped a bucket due to security config — the member list is incomplete.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
if chatID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
|
||||
}
|
||||
if !strings.HasPrefix(chatID, "oc_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id")
|
||||
}
|
||||
if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size")
|
||||
}
|
||||
if n := runtime.Int("page-limit"); n < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
|
||||
}
|
||||
if n := runtime.Int("page-delay"); n < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-delay must be a non-negative integer").WithParam("--page-delay")
|
||||
}
|
||||
_, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
dry := common.NewDryRunAPI()
|
||||
if chatMembersShouldAutoPaginate(runtime) {
|
||||
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
|
||||
}
|
||||
params, _ := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
|
||||
return dry.
|
||||
GET(fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))).
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
warnIfConflictingPagingFlags(runtime)
|
||||
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
res, err := fetchChatMembers(ctx, runtime, chatID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The truncation signal is the whole reason this is a dedicated shortcut:
|
||||
// surface it loudly so an agent never mistakes a capped list for a
|
||||
// complete one.
|
||||
if len(res.truncations) > 0 {
|
||||
writeChatMembersTruncationWarning(runtime.IO().ErrOut, res.truncations)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Found %d user(s) and %d bot(s)\n", len(res.users), len(res.bots))
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"users": res.users,
|
||||
"bots": res.bots,
|
||||
"truncations": res.truncations,
|
||||
"has_more": res.hasMore,
|
||||
"page_token": res.pageToken,
|
||||
}
|
||||
if res.userTotal != nil {
|
||||
outData["user_total"] = res.userTotal
|
||||
}
|
||||
if res.botTotal != nil {
|
||||
outData["bot_total"] = res.botTotal
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(res.users) + len(res.bots)}, func(w io.Writer) {
|
||||
renderChatMembersPretty(w, chatID, res)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// chatMembersResult is the aggregated view across one or more pages.
|
||||
type chatMembersResult struct {
|
||||
users []interface{}
|
||||
bots []interface{}
|
||||
truncations []interface{}
|
||||
userTotal interface{}
|
||||
botTotal interface{}
|
||||
hasMore bool
|
||||
pageToken string
|
||||
}
|
||||
|
||||
// effectiveChatMembersPageSize resolves the page_size to request. When draining
|
||||
// every page (--page-all) and the caller did NOT explicitly set --page-size, it
|
||||
// uses the maximum so a full walk takes the fewest round-trips. An explicit
|
||||
// --page-size is always honored; without --page-all the smaller default is kept
|
||||
// as a sensible single-page preview size.
|
||||
func effectiveChatMembersPageSize(runtime *common.RuntimeContext) int {
|
||||
if chatMembersShouldAutoPaginate(runtime) && !runtime.Changed("page-size") {
|
||||
return chatMembersListMaxPageSize
|
||||
}
|
||||
if n := runtime.Int("page-size"); n > 0 {
|
||||
return n
|
||||
}
|
||||
return chatMembersListDefaultPageSize
|
||||
}
|
||||
|
||||
// chatMembersShouldAutoPaginate reports whether the fetch loop should walk
|
||||
// every page. An explicit --page-token disables the auto loop because the
|
||||
// caller supplied a specific cursor (single-page fetch).
|
||||
func chatMembersShouldAutoPaginate(runtime *common.RuntimeContext) bool {
|
||||
if strings.TrimSpace(runtime.Str("page-token")) != "" {
|
||||
return false
|
||||
}
|
||||
return runtime.Bool("page-all")
|
||||
}
|
||||
|
||||
// buildChatMembersParams builds the query params for one page request. The
|
||||
// startToken (when non-empty) seeds the page_token; the loop overrides it per
|
||||
// page. Returns the params and the normalized member-types CSV (already
|
||||
// validated by Validate, so the error is only a defensive guard).
|
||||
func buildChatMembersParams(runtime *common.RuntimeContext, startToken string) (map[string]interface{}, error) {
|
||||
memberTypes, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"member_id_type": runtime.Str("member-id-type"),
|
||||
"page_size": effectiveChatMembersPageSize(runtime),
|
||||
}
|
||||
if memberTypes != "" {
|
||||
params["member_types"] = memberTypes
|
||||
}
|
||||
if startToken != "" {
|
||||
params["page_token"] = startToken
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// fetchChatMembers walks the list_members endpoint, honoring the four
|
||||
// pagination flags the same way the generic --page-all path does. It merges
|
||||
// each page into the aggregate as it arrives (rather than buffering every raw
|
||||
// page), so peak memory is just the aggregated members plus the single most
|
||||
// recent page — important for large groups under --page-limit 0.
|
||||
func fetchChatMembers(ctx context.Context, runtime *common.RuntimeContext, chatID string) (*chatMembersResult, error) {
|
||||
auto := chatMembersShouldAutoPaginate(runtime)
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
pageDelay := runtime.Int("page-delay")
|
||||
apiPath := fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))
|
||||
|
||||
params, err := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := newChatMembersResult()
|
||||
var lastData map[string]interface{}
|
||||
pageToken := strings.TrimSpace(runtime.Str("page-token"))
|
||||
for page := 0; ; page++ {
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", page+1)
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addMemberBuckets(res, data)
|
||||
lastData = data
|
||||
|
||||
hasMore, nextToken := common.PaginationMeta(data)
|
||||
if !auto {
|
||||
break
|
||||
}
|
||||
if !hasMore || nextToken == "" {
|
||||
break
|
||||
}
|
||||
if nextToken == pageToken {
|
||||
// Guard against a buggy server echoing the same cursor with
|
||||
// has_more=true: without --page-limit we would loop forever.
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "Stopping pagination: server returned a non-advancing page_token.")
|
||||
break
|
||||
}
|
||||
if pageLimit > 0 && page+1 >= pageLimit {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", pageLimit)
|
||||
break
|
||||
}
|
||||
pageToken = nextToken
|
||||
// Throttle between pages (only reached when another page follows), so
|
||||
// draining a large untruncated list doesn't hammer the API.
|
||||
if pageDelay > 0 {
|
||||
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if lastData != nil {
|
||||
applyLastPageSignals(res, lastData)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// newChatMembersResult returns an empty aggregate with non-nil buckets so the
|
||||
// JSON output always carries arrays (never null).
|
||||
func newChatMembersResult() *chatMembersResult {
|
||||
return &chatMembersResult{
|
||||
users: []interface{}{},
|
||||
bots: []interface{}{},
|
||||
truncations: []interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// addMemberBuckets appends one page's users[] and bots[] into the aggregate.
|
||||
// Concatenating every bucket is what avoids dropping bots[] — the bug the
|
||||
// generic single-array --page-all merger would hit on this multi-bucket shape.
|
||||
func addMemberBuckets(res *chatMembersResult, data map[string]interface{}) {
|
||||
if u, ok := data["users"].([]interface{}); ok {
|
||||
res.users = append(res.users, u...)
|
||||
}
|
||||
if b, ok := data["bots"].([]interface{}); ok {
|
||||
res.bots = append(res.bots, b...)
|
||||
}
|
||||
}
|
||||
|
||||
// applyLastPageSignals copies the per-request signals from the FINAL page:
|
||||
// has_more / page_token / truncations / totals. These must come from the last
|
||||
// page, not page 1: truncations[] is emitted only on the final page (empty
|
||||
// earlier), so reading it sooner would hide a server-side cap; user_total /
|
||||
// bot_total are server-wide counts, and taking the final page's value keeps a
|
||||
// single, consistent source rather than a possibly-stale earlier count.
|
||||
func applyLastPageSignals(res *chatMembersResult, data map[string]interface{}) {
|
||||
res.hasMore, res.pageToken = common.PaginationMeta(data)
|
||||
if t, ok := data["truncations"].([]interface{}); ok {
|
||||
res.truncations = t
|
||||
}
|
||||
res.userTotal = data["user_total"]
|
||||
res.botTotal = data["bot_total"]
|
||||
}
|
||||
|
||||
// mergeChatMemberPages folds a slice of page payloads into one aggregate. It is
|
||||
// the same logic fetchChatMembers applies incrementally, kept as a pure
|
||||
// function so the multi-bucket merge + last-page-signal semantics are unit
|
||||
// tested in one place.
|
||||
func mergeChatMemberPages(pages []map[string]interface{}) *chatMembersResult {
|
||||
res := newChatMembersResult()
|
||||
if len(pages) == 0 {
|
||||
return res
|
||||
}
|
||||
for _, data := range pages {
|
||||
addMemberBuckets(res, data)
|
||||
}
|
||||
applyLastPageSignals(res, pages[len(pages)-1])
|
||||
return res
|
||||
}
|
||||
|
||||
// normalizeMemberTypes validates the --member-types slice (already CSV-split by
|
||||
// cobra) into a lowercased, deduped CSV string. Empty input is a no-op (return
|
||||
// the API's default of all types). Any element outside {user, bot} is rejected.
|
||||
func normalizeMemberTypes(raw []string) (string, error) {
|
||||
if len(raw) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, p := range raw {
|
||||
p = strings.TrimSpace(strings.ToLower(p))
|
||||
if p != "user" && p != "bot" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot", p).WithParam("--member-types")
|
||||
}
|
||||
if _, dup := seen[p]; dup {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
return strings.Join(out, ","), nil
|
||||
}
|
||||
|
||||
// warnIfConflictingPagingFlags mirrors the wiki list shortcuts: --page-token
|
||||
// wins (single-page fetch from the supplied cursor) and --page-all is ignored.
|
||||
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {
|
||||
if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") {
|
||||
fmt.Fprintln(runtime.IO().ErrOut,
|
||||
"warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)")
|
||||
}
|
||||
}
|
||||
|
||||
// writeChatMembersTruncationWarning emits a stderr warning for every
|
||||
// server-side bucket cap reported in truncations[]. It uses the repo's plain
|
||||
// "warning: <code>: <message>" convention (see shortcuts/common/runner.go and
|
||||
// +chat-list's bot_strip_p2p) — no emoji, so it stays legible in CI logs and
|
||||
// pipes regardless of terminal encoding.
|
||||
func writeChatMembersTruncationWarning(w io.Writer, truncations []interface{}) {
|
||||
for _, t := range truncations {
|
||||
tm, ok := t.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
memberType := valueOrAll(tm["member_type"])
|
||||
limit := tm["limit"]
|
||||
fmt.Fprintf(w, "warning: members_truncated: %s bucket capped at %v by server security config; the member list is INCOMPLETE\n", memberType, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrAll(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "member"
|
||||
}
|
||||
|
||||
func renderChatMembersPretty(w io.Writer, chatID string, res *chatMembersResult) {
|
||||
fmt.Fprintf(w, "Chat: %s\n", chatID)
|
||||
// Show the server-wide total next to the fetched count: when truncated or
|
||||
// paged, total can far exceed len(users)/len(bots), and that gap is exactly
|
||||
// what tells the reader how incomplete the list is.
|
||||
fmt.Fprintf(w, "Users (%d%s):\n", len(res.users), totalSuffix(res.userTotal, len(res.users)))
|
||||
for i, u := range res.users {
|
||||
m, _ := u.(map[string]interface{})
|
||||
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
|
||||
}
|
||||
fmt.Fprintf(w, "Bots (%d%s):\n", len(res.bots), totalSuffix(res.botTotal, len(res.bots)))
|
||||
for i, b := range res.bots {
|
||||
m, _ := b.(map[string]interface{})
|
||||
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
|
||||
}
|
||||
if len(res.truncations) > 0 {
|
||||
fmt.Fprintln(w, "warning: result truncated by server security config (see truncations[]); the list is INCOMPLETE")
|
||||
}
|
||||
if res.hasMore {
|
||||
fmt.Fprint(w, "More pages available; pass --page-all (and --page-limit 0 for everything)")
|
||||
if res.pageToken != "" {
|
||||
fmt.Fprintf(w, ", or --page-token %s to resume", res.pageToken)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrDash(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
// totalSuffix renders " of <total>" when the server-reported total exceeds the
|
||||
// number actually fetched (so a truncated/partial bucket is obvious), and ""
|
||||
// when the total is absent or already matches the fetched count.
|
||||
func totalSuffix(total interface{}, fetched int) string {
|
||||
n, ok := toInt(total)
|
||||
if !ok || n <= fetched {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" of %d", n)
|
||||
}
|
||||
|
||||
// toInt coerces a JSON-decoded number (float64 / json.Number / int) to int.
|
||||
func toInt(v interface{}) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
case json.Number:
|
||||
if i, err := n.Int64(); err == nil {
|
||||
return int(i), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// page builds one list_members page payload shaped like the data object the
|
||||
// server returns (users[]/bots[]/truncations[] plus paging + totals).
|
||||
func cmlPage(users, bots, truncations []interface{}, hasMore bool, pageToken string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"users": users,
|
||||
"bots": bots,
|
||||
"truncations": truncations,
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
"user_total": 324,
|
||||
"bot_total": 2,
|
||||
}
|
||||
}
|
||||
|
||||
func us(ids ...string) []interface{} {
|
||||
out := make([]interface{}, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]interface{}{"member_id": id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_MergesUsersAndBots covers Bug 1: every list bucket
|
||||
// (users AND bots) must be concatenated across pages, not just one of them.
|
||||
func TestMergeChatMemberPages_MergesUsersAndBots(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
|
||||
cmlPage(us("u3"), us("b2", "b3"), []interface{}{}, false, ""),
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3 merged, got %d", len(res.users))
|
||||
}
|
||||
if len(res.bots) != 3 {
|
||||
t.Errorf("bots: want 3 merged, got %d", len(res.bots))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_TruncationsFromLastPage covers Bug 2: truncations[]
|
||||
// is emitted only on the final page, so the merged view must take it from the
|
||||
// last page rather than inherit page 1's empty slice.
|
||||
func TestMergeChatMemberPages_TruncationsFromLastPage(t *testing.T) {
|
||||
limit := []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1"), us("b1"), []interface{}{}, true, "p2"),
|
||||
cmlPage(us("u2"), nil, limit, false, ""),
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if len(res.truncations) != 1 {
|
||||
t.Fatalf("truncations: want last page's 1 entry, got %d (%v)", len(res.truncations), res.truncations)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_HasMoreAndTokenFromLastPage guards that paging
|
||||
// signals come from the final page (so a --page-limit cutoff is visible).
|
||||
func TestMergeChatMemberPages_HasMoreAndTokenFromLastPage(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1"), nil, nil, true, "p2"),
|
||||
cmlPage(us("u2"), nil, nil, true, "p3"), // loop stopped early; server still has more
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if !res.hasMore {
|
||||
t.Error("has_more: want true from last page")
|
||||
}
|
||||
if res.pageToken != "p3" {
|
||||
t.Errorf("page_token: want last page's p3, got %q", res.pageToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_TotalsFromLastPage verifies user_total / bot_total
|
||||
// are taken from the final page (not an earlier, possibly-different value).
|
||||
func TestMergeChatMemberPages_TotalsFromLastPage(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
{"users": us("u1"), "user_total": 999, "bot_total": 7, "has_more": true, "page_token": "p2"},
|
||||
{"users": us("u2"), "user_total": 324, "bot_total": 2, "has_more": false, "page_token": ""},
|
||||
}
|
||||
res := mergeChatMemberPages(pages)
|
||||
if n, _ := toInt(res.userTotal); n != 324 {
|
||||
t.Errorf("user_total: want last page's 324, got %v", res.userTotal)
|
||||
}
|
||||
if n, _ := toInt(res.botTotal); n != 2 {
|
||||
t.Errorf("bot_total: want last page's 2, got %v", res.botTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMembersValidate covers --chat-id presence + oc_ prefix enforcement.
|
||||
func TestChatMembersValidate(t *testing.T) {
|
||||
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
|
||||
})
|
||||
cases := []struct {
|
||||
name string
|
||||
chatID string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid oc_", "oc_abc", false},
|
||||
{"empty", "", true},
|
||||
{"missing oc_ prefix", "abc123", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": c.chatID}, nil, nil)
|
||||
err := ImChatMembersList.Validate(context.Background(), rt)
|
||||
if c.wantErr {
|
||||
assertValidationError(t, c.name, err, "--chat-id")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%s: unexpected error %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertValidationError checks err satisfies the repo's typed-error contract for
|
||||
// a validation failure: a *errs.ValidationError carrying the expected Param, and
|
||||
// problem metadata of category validation / subtype invalid_argument.
|
||||
func assertValidationError(t *testing.T, ctx string, err error, wantParam string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Errorf("%s: want *errs.ValidationError, got %T (%v)", ctx, err, err)
|
||||
return
|
||||
}
|
||||
if ve.Param != wantParam {
|
||||
t.Errorf("%s: Param = %q, want %q", ctx, ve.Param, wantParam)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%s: problem = %+v (ok=%v), want category=%s subtype=%s", ctx, p, ok, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMemberTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in []string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{nil, "", false},
|
||||
{[]string{"user", "bot"}, "user,bot", false},
|
||||
{[]string{"USER", "user"}, "user", false}, // lowercased + deduped
|
||||
{[]string{"admin"}, "", true},
|
||||
{[]string{""}, "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := normalizeMemberTypes(c.in)
|
||||
if c.wantErr {
|
||||
assertValidationError(t, fmt.Sprintf("normalizeMemberTypes(%v)", c.in), err, "--member-types")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("normalizeMemberTypes(%v): unexpected error %v", c.in, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("normalizeMemberTypes(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveChatMembersPageSize covers the --page-all max-page-size behavior:
|
||||
// drain with no explicit size → max; explicit size → honored; single page → default.
|
||||
func TestEffectiveChatMembersPageSize(t *testing.T) {
|
||||
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
|
||||
})
|
||||
cases := []struct {
|
||||
name string
|
||||
b map[string]bool
|
||||
ints map[string]int
|
||||
want int
|
||||
}{
|
||||
{"page-all, size unset -> max", map[string]bool{"page-all": true}, nil, chatMembersListMaxPageSize},
|
||||
{"page-all, size explicit -> honored", map[string]bool{"page-all": true}, map[string]int{"page-size": 15}, 15},
|
||||
{"single page, size unset -> default", nil, nil, chatMembersListDefaultPageSize},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": "oc_x"}, c.b, c.ints)
|
||||
if got := effectiveChatMembersPageSize(rt); got != c.want {
|
||||
t.Errorf("%s: want %d, got %d", c.name, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newChatMembersTestRuntime registers the shortcut's flags and returns a
|
||||
// user-identity runtime wired to the given RoundTripper for multi-page mocking.
|
||||
func newChatMembersTestRuntime(t *testing.T, rt http.RoundTripper, str map[string]string, b map[string]bool, ints map[string]int) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
runtime := newUserShortcutRuntime(t, rt)
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("chat-id", "", "")
|
||||
cmd.Flags().String("member-id-type", "open_id", "")
|
||||
cmd.Flags().StringSlice("member-types", nil, "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
for k, v := range str {
|
||||
if err := cmd.Flags().Set(k, v); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
for k, v := range b {
|
||||
if err := cmd.Flags().Set(k, strconv.FormatBool(v)); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
for k, v := range ints {
|
||||
if err := cmd.Flags().Set(k, strconv.Itoa(v)); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
runtime.Cmd = cmd
|
||||
return runtime
|
||||
}
|
||||
|
||||
// TestFetchChatMembers_PageAllMergesBucketsAndTruncations exercises the full
|
||||
// fetch loop over mocked pages: users/bots merge across pages and the final
|
||||
// page's truncations[] survives.
|
||||
func TestFetchChatMembers_PageAllMergesBucketsAndTruncations(t *testing.T) {
|
||||
calls := 0
|
||||
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/oc_test/members/list") {
|
||||
return shortcutJSONResponse(404, map[string]interface{}{"code": 1}), nil
|
||||
}
|
||||
calls++
|
||||
token := req.URL.Query().Get("page_token")
|
||||
if token == "" {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
|
||||
}), nil
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u3"), us("b2"), []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}, false, ""),
|
||||
}), nil
|
||||
})
|
||||
runtime := newChatMembersTestRuntime(t, rt,
|
||||
map[string]string{"chat-id": "oc_test"},
|
||||
map[string]bool{"page-all": true},
|
||||
map[string]int{"page-size": 2, "page-limit": 0, "page-delay": 0})
|
||||
|
||||
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchChatMembers: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("want 2 page calls, got %d", calls)
|
||||
}
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3, got %d", len(res.users))
|
||||
}
|
||||
if len(res.bots) != 2 {
|
||||
t.Errorf("bots: want 2, got %d", len(res.bots))
|
||||
}
|
||||
if len(res.truncations) != 1 {
|
||||
t.Errorf("truncations: want 1 from last page, got %d", len(res.truncations))
|
||||
}
|
||||
if res.hasMore {
|
||||
t.Error("has_more: want false after draining all pages")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchChatMembers_PageLimitStops verifies --page-limit caps the loop and
|
||||
// leaves has_more=true so the caller knows the result is incomplete.
|
||||
func TestFetchChatMembers_PageLimitStops(t *testing.T) {
|
||||
seq := 0
|
||||
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
// Every page reports more pages available, with an advancing token so the
|
||||
// loop is stopped by --page-limit, not the non-advancing-token guard.
|
||||
seq++
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u"), nil, nil, true, fmt.Sprintf("p%d", seq)),
|
||||
}), nil
|
||||
})
|
||||
runtime := newChatMembersTestRuntime(t, rt,
|
||||
map[string]string{"chat-id": "oc_test"},
|
||||
map[string]bool{"page-all": true},
|
||||
map[string]int{"page-size": 1, "page-limit": 3, "page-delay": 0})
|
||||
|
||||
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchChatMembers: %v", err)
|
||||
}
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3 (capped at page-limit), got %d", len(res.users))
|
||||
}
|
||||
if !res.hasMore {
|
||||
t.Error("has_more: want true (loop cut short by page-limit)")
|
||||
}
|
||||
errOut := runtime.IO().ErrOut.(*bytes.Buffer)
|
||||
if !strings.Contains(errOut.String(), "reached page limit (3)") {
|
||||
t.Errorf("want page-limit notice on stderr, got: %s", errOut.String())
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
ImChatCreate,
|
||||
ImChatList,
|
||||
ImChatMembersList,
|
||||
ImChatMessageList,
|
||||
ImChatSearch,
|
||||
ImChatUpdate,
|
||||
|
||||
@@ -51,8 +51,9 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
|
||||
// original message as read after a reply/reply-all/forward operation.
|
||||
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
|
||||
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
|
||||
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
|
||||
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
|
||||
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
|
||||
}
|
||||
|
||||
// hintReadReceiptRequest prints a stderr tip when a message that the caller
|
||||
|
||||
@@ -465,19 +465,14 @@ func TestPrintWatchOutputSchema(t *testing.T) {
|
||||
// TestHintMarkAsRead verifies hint mark as read.
|
||||
func TestHintMarkAsRead(t *testing.T) {
|
||||
rt, _, stderr := newOutputRuntime(t)
|
||||
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
|
||||
// Inject ANSI escape + message ID to verify sanitization
|
||||
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
|
||||
out := stderr.String()
|
||||
if strings.Contains(out, "\x1b[") {
|
||||
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "\nnext") {
|
||||
t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
|
||||
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
|
||||
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
|
||||
if !strings.Contains(out, "msg-123") {
|
||||
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func messageManageID(suffix string) string {
|
||||
return "msg_abcdefghijklmnop_" + suffix
|
||||
}
|
||||
|
||||
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/" + endpoint,
|
||||
Body: body,
|
||||
}
|
||||
reg.Register(stub)
|
||||
return stub
|
||||
}
|
||||
|
||||
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
|
||||
t.Helper()
|
||||
success, ok := data["success_message_ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
|
||||
}
|
||||
failed, ok := data["failed_message_ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
|
||||
}
|
||||
return success, failed
|
||||
}
|
||||
|
||||
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for %s, got nil", param)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed Problem for %s, got %T", param, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if validationErr.Param != param {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, param)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected failed precondition error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed Problem, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
|
||||
}
|
||||
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
|
||||
}
|
||||
|
||||
cases := [][]string{
|
||||
{""},
|
||||
{" id_with_leading_space_12345"},
|
||||
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
|
||||
{"1234567890123456"},
|
||||
{"short"},
|
||||
{"msg_abcdefghijklmnop!"},
|
||||
{"msg_abcdefghijklmnop\t"},
|
||||
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
|
||||
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, err := normalizeMessageManageIDs(tc)
|
||||
requireMessageManageValidationParam(t, err, "--message-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_Metadata(t *testing.T) {
|
||||
if MailMessageModify.Command != "+message-modify" {
|
||||
t.Fatalf("Command = %q", MailMessageModify.Command)
|
||||
}
|
||||
if MailMessageModify.Risk != "write" {
|
||||
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
|
||||
}
|
||||
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
|
||||
}
|
||||
requiredScopes := map[string]bool{
|
||||
"mail:user_mailbox.message:modify": true,
|
||||
}
|
||||
for _, scope := range MailMessageModify.Scopes {
|
||||
delete(requiredScopes, scope)
|
||||
}
|
||||
if len(requiredScopes) != 0 {
|
||||
t.Errorf("Scopes missing %v", requiredScopes)
|
||||
}
|
||||
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
|
||||
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
|
||||
}
|
||||
flags := map[string]common.Flag{}
|
||||
for _, fl := range MailMessageModify.Flags {
|
||||
flags[fl.Name] = fl
|
||||
}
|
||||
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
|
||||
if _, ok := flags[name]; !ok {
|
||||
t.Fatalf("missing --%s flag", name)
|
||||
}
|
||||
}
|
||||
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
|
||||
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_Metadata(t *testing.T) {
|
||||
if MailMessageTrash.Command != "+message-trash" {
|
||||
t.Fatalf("Command = %q", MailMessageTrash.Command)
|
||||
}
|
||||
if MailMessageTrash.Risk != "high-risk-write" {
|
||||
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
|
||||
}
|
||||
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
|
||||
}
|
||||
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
|
||||
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
token := auth.GetStoredToken("test-app", "ou_testuser")
|
||||
if token == nil {
|
||||
t.Fatal("expected test token")
|
||||
}
|
||||
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
id := messageManageID("1")
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--remove-label-ids", "UNREAD",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
|
||||
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--remove-label-ids", "read_receipt_request",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
|
||||
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
|
||||
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-label-ids", "unread,customA",
|
||||
"--remove-label-ids", "FLAGGED",
|
||||
"--add-folder", "folderA",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if got := body["add_folder"]; got != "folderA" {
|
||||
t.Errorf("add_folder = %v, want folderA", got)
|
||||
}
|
||||
addLabels := body["add_label_ids"].([]interface{})
|
||||
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
|
||||
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if removeLabels[0] != "FLAGGED" {
|
||||
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 1 || success[0] != id || len(failed) != 0 {
|
||||
t.Errorf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-label-ids", "unread",
|
||||
"--remove-label-ids", "UNREAD",
|
||||
}, f, stdout)
|
||||
requireMessageManageValidationParam(t, err, "--add-label-ids")
|
||||
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
|
||||
t.Fatalf("error = %v, want label intersection validation", err)
|
||||
}
|
||||
|
||||
err = runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-folder", "trash",
|
||||
}, f, stdout)
|
||||
requireMessageManageValidationParam(t, err, "--add-folder")
|
||||
if !strings.Contains(err.Error(), "use +message-trash") {
|
||||
t.Fatalf("error = %v, want TRASH validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id1 + "," + id2 + "," + id1,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
|
||||
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
ids := make([]string, 41)
|
||||
for i := range ids {
|
||||
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
|
||||
}
|
||||
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", strings.Join(ids, ","),
|
||||
"--add-folder", "archive",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
for idx, stub := range []*httpmock.Stub{first, second, third} {
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
|
||||
}
|
||||
messageIDs := body["message_ids"].([]interface{})
|
||||
want := []int{20, 20, 1}[idx]
|
||||
if len(messageIDs) != want {
|
||||
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
|
||||
}
|
||||
if body["add_folder"] != "ARCHIVED" {
|
||||
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
|
||||
}
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 21 || len(failed) != 20 {
|
||||
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-folder", "archive",
|
||||
}, f, stdout)
|
||||
requireMessageManageFailedPrecondition(t, err)
|
||||
}
|
||||
|
||||
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
"--add-label-ids", "customA",
|
||||
"--add-folder", "folderA",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
`/user_mailboxes/me/messages/batch_modify`,
|
||||
`validation_api_plan`,
|
||||
`/user_mailboxes/me/labels/customA`,
|
||||
`/user_mailboxes/me/folders/folderA`,
|
||||
`will_validate`,
|
||||
`batch_size`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q; got %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
|
||||
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
|
||||
}
|
||||
|
||||
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
err = runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err with --yes: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if got := len(body["message_ids"].([]interface{})); got != 2 {
|
||||
t.Fatalf("message_ids len = %d, want 2", got)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 2 || len(failed) != 0 {
|
||||
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id,
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
requireMessageManageFailedPrecondition(t, err)
|
||||
}
|
||||
|
||||
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
cases := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "trash newline in repeated flag",
|
||||
shortcut: MailMessageTrash,
|
||||
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
|
||||
},
|
||||
{
|
||||
name: "trash tab in csv flag",
|
||||
shortcut: MailMessageTrash,
|
||||
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
|
||||
},
|
||||
{
|
||||
name: "modify space in repeated flag",
|
||||
shortcut: MailMessageModify,
|
||||
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
|
||||
},
|
||||
{
|
||||
name: "modify space in csv flag",
|
||||
shortcut: MailMessageModify,
|
||||
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
|
||||
t.Fatalf("error = %v, want whitespace/control validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type messageModifyInput struct {
|
||||
MessageIDs []string
|
||||
AddLabelIDs []string
|
||||
RemoveLabelIDs []string
|
||||
AddFolder string
|
||||
CustomLabelIDs []string
|
||||
CustomFolderID string
|
||||
ValidationAPIPlans []validationAPIPlan
|
||||
}
|
||||
|
||||
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
|
||||
// state labels, or a folder move to existing messages in batches of 20.
|
||||
var MailMessageModify = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message-modify",
|
||||
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||
ConditionalScopes: []string{
|
||||
"mail:user_mailbox.folder:read",
|
||||
},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
|
||||
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
|
||||
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
|
||||
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
|
||||
},
|
||||
Validate: validateMessageModify,
|
||||
DryRun: dryRunMessageModify,
|
||||
Execute: executeMessageModify,
|
||||
}
|
||||
|
||||
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := buildMessageModifyInput(rt)
|
||||
return err
|
||||
}
|
||||
|
||||
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
input, _ := buildMessageModifyInput(rt)
|
||||
api := common.NewDryRunAPI().
|
||||
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
|
||||
Set("batch_size", mailMessageManageBatchSize).
|
||||
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
|
||||
Set("validation_api_plan", input.ValidationAPIPlans)
|
||||
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
|
||||
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
input, err := buildMessageModifyInput(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
|
||||
emitMessageManageSummary(rt, messageManageSummary{
|
||||
SuccessMessageIDs: input.MessageIDs,
|
||||
FailedMessageIDs: []messageManageFailure{},
|
||||
}, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
|
||||
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||
if err != nil {
|
||||
for _, id := range batch {
|
||||
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||
}
|
||||
emitMessageManageSummary(rt, summary, false)
|
||||
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||
return mailFailedPreconditionError("all message modify batches failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
|
||||
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
customLabels := append(customAddLabels, customRemoveLabels...)
|
||||
customFolderID := ""
|
||||
if customFolder {
|
||||
customFolderID = folder
|
||||
}
|
||||
return messageModifyInput{
|
||||
MessageIDs: messageIDs,
|
||||
AddLabelIDs: addLabels,
|
||||
RemoveLabelIDs: removeLabels,
|
||||
AddFolder: folder,
|
||||
CustomLabelIDs: customLabels,
|
||||
CustomFolderID: customFolderID,
|
||||
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
|
||||
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
|
||||
// runner requires --yes before Execute.
|
||||
var MailMessageTrash = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message-trash",
|
||||
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
|
||||
},
|
||||
Validate: validateMessageTrash,
|
||||
DryRun: dryRunMessageTrash,
|
||||
Execute: executeMessageTrash,
|
||||
}
|
||||
|
||||
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
return err
|
||||
}
|
||||
|
||||
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
api := common.NewDryRunAPI().
|
||||
Desc("Soft-delete messages sequentially in batches of 20").
|
||||
Set("batch_size", mailMessageManageBatchSize).
|
||||
Set("batches", chunkMessageManageIDs(messageIDs))
|
||||
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
|
||||
Body(map[string]interface{}{"message_ids": batch})
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
|
||||
map[string]interface{}{"message_ids": batch})
|
||||
if err != nil {
|
||||
for _, id := range batch {
|
||||
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||
}
|
||||
emitMessageManageSummary(rt, summary, false)
|
||||
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||
return mailFailedPreconditionError("all message trash batches failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const mailMessageManageBatchSize = 20
|
||||
|
||||
var messageManageSystemLabels = map[string]string{
|
||||
"UNREAD": "UNREAD",
|
||||
"IMPORTANT": "IMPORTANT",
|
||||
"OTHER": "OTHER",
|
||||
"FLAGGED": "FLAGGED",
|
||||
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
|
||||
}
|
||||
|
||||
var messageManageSystemFolders = map[string]string{
|
||||
"INBOX": "INBOX",
|
||||
"SENT": "SENT",
|
||||
"SPAM": "SPAM",
|
||||
"ARCHIVE": "ARCHIVED",
|
||||
"ARCHIVED": "ARCHIVED",
|
||||
}
|
||||
|
||||
type messageManageSummary struct {
|
||||
SuccessMessageIDs []string `json:"success_message_ids"`
|
||||
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
|
||||
}
|
||||
|
||||
type messageManageFailure struct {
|
||||
MessageID string `json:"message_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type validationAPIPlan struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
WillValidate bool `json:"will_validate"`
|
||||
}
|
||||
|
||||
func normalizeMessageManageIDs(raw []string) ([]string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||
}
|
||||
parts, err := splitMessageManageIDTokens(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
if id != part {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
|
||||
}
|
||||
if err := validateMessageManageID(id, i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func splitMessageManageIDTokens(raw []string) ([]string, error) {
|
||||
parts := make([]string, 0, len(raw))
|
||||
for i, token := range raw {
|
||||
for _, r := range token {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
|
||||
}
|
||||
}
|
||||
parts = append(parts, strings.Split(token, ",")...)
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func validateMessageManageID(id string, index int) error {
|
||||
if len(id) < 16 {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
|
||||
}
|
||||
if strings.Trim(id, "0123456789") == "" {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
|
||||
}
|
||||
for _, r := range id {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
|
||||
}
|
||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '+', '/', '=', '_', '-':
|
||||
continue
|
||||
default:
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
|
||||
labels := make([]string, 0, len(raw))
|
||||
custom := make([]string, 0, len(raw))
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
for i, part := range raw {
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
|
||||
}
|
||||
if id != part {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
|
||||
}
|
||||
normalized := id
|
||||
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
|
||||
normalized = system
|
||||
} else {
|
||||
custom = append(custom, id)
|
||||
}
|
||||
if _, ok := seen[normalized]; ok {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
labels = append(labels, normalized)
|
||||
}
|
||||
if len(labels) > 20 {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
|
||||
}
|
||||
return labels, custom, nil
|
||||
}
|
||||
|
||||
func validateLabelIntersection(add, remove []string) error {
|
||||
removeSet := make(map[string]struct{}, len(remove))
|
||||
for _, id := range remove {
|
||||
removeSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range add {
|
||||
if _, ok := removeSet[id]; ok {
|
||||
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeMessageManageFolder(raw string) (string, bool, error) {
|
||||
if raw == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
folder := strings.TrimSpace(raw)
|
||||
if folder == "" {
|
||||
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
|
||||
}
|
||||
if folder != raw {
|
||||
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
|
||||
}
|
||||
if strings.EqualFold(folder, "TRASH") {
|
||||
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
|
||||
}
|
||||
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
|
||||
return system, false, nil
|
||||
}
|
||||
return folder, true, nil
|
||||
}
|
||||
|
||||
func chunkMessageManageIDs(ids []string) [][]string {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
|
||||
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
|
||||
end := start + mailMessageManageBatchSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
chunks = append(chunks, ids[start:end])
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := validateLabelReadScope(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range ids {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
|
||||
return mailDecorateProblemMessage(err, "label not found: %s", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
if err := validateFolderReadScope(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
|
||||
return mailDecorateProblemMessage(err, "folder not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
|
||||
body := map[string]interface{}{"message_ids": ids}
|
||||
if len(addLabels) > 0 {
|
||||
body["add_label_ids"] = addLabels
|
||||
}
|
||||
if len(removeLabels) > 0 {
|
||||
body["remove_label_ids"] = removeLabels
|
||||
}
|
||||
if addFolder != "" {
|
||||
body["add_folder"] = addFolder
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
|
||||
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
|
||||
seenLabels := map[string]struct{}{}
|
||||
for _, id := range customLabels {
|
||||
if _, ok := seenLabels[id]; ok {
|
||||
continue
|
||||
}
|
||||
seenLabels[id] = struct{}{}
|
||||
plans = append(plans, validationAPIPlan{
|
||||
Method: "GET",
|
||||
Path: mailboxPath(mailboxID, "labels", id),
|
||||
WillValidate: true,
|
||||
})
|
||||
}
|
||||
if customFolder != "" {
|
||||
plans = append(plans, validationAPIPlan{
|
||||
Method: "GET",
|
||||
Path: mailboxPath(mailboxID, "folders", customFolder),
|
||||
WillValidate: true,
|
||||
})
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
|
||||
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
|
||||
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
|
||||
if noAPICalls {
|
||||
fmt.Fprintln(w, "No changes requested; no API calls were made.")
|
||||
}
|
||||
for _, item := range summary.FailedMessageIDs {
|
||||
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,8 +10,6 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MailMessage,
|
||||
MailMessages,
|
||||
MailMessageModify,
|
||||
MailMessageTrash,
|
||||
MailThread,
|
||||
MailTriage,
|
||||
MailWatch,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user