mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 02:54:04 +08:00
Compare commits
24 Commits
feat/lark-
...
codex/html
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da237cc73a | ||
|
|
9413e7cd8b | ||
|
|
047d729f72 | ||
|
|
1a9f637866 | ||
|
|
34c4ba5581 | ||
|
|
9a6ba41684 | ||
|
|
f495cbb166 | ||
|
|
6f95c5eb22 | ||
|
|
4e2cbea94e | ||
|
|
f98dbfe247 | ||
|
|
40ea4d60ef | ||
|
|
f0b6f35fee | ||
|
|
91d785f92f | ||
|
|
e621c6e50f | ||
|
|
869a259d4e | ||
|
|
ee46e22abd | ||
|
|
b76dc18c2f | ||
|
|
85679d4258 | ||
|
|
1ba4f3973c | ||
|
|
c45ff569c4 | ||
|
|
a1506cdffb | ||
|
|
3595356ea1 | ||
|
|
73be1d06ec | ||
|
|
cccf025599 |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -263,13 +263,19 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
python-version: '3.x'
|
||||||
|
- name: Resolve CLI E2E domains
|
||||||
|
id: e2e_domains
|
||||||
|
run: node scripts/e2e_domains.js
|
||||||
- name: Build lark-cli
|
- name: Build lark-cli
|
||||||
|
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
run: make build
|
run: make build
|
||||||
- name: Run dry-run E2E tests
|
- name: Run dry-run E2E tests
|
||||||
env:
|
env:
|
||||||
@@ -277,7 +283,28 @@ jobs:
|
|||||||
LARKSUITE_CLI_APP_ID: dry-run
|
LARKSUITE_CLI_APP_ID: dry-run
|
||||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||||
LARKSUITE_CLI_BRAND: feishu
|
LARKSUITE_CLI_BRAND: feishu
|
||||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||||
|
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||||
|
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
||||||
|
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
||||||
|
run: |
|
||||||
|
if [ "$E2E_MODE" = "skip" ]; then
|
||||||
|
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
||||||
|
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||||
|
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
||||||
|
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
||||||
|
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
||||||
|
fi
|
||||||
|
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
||||||
|
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
||||||
|
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
||||||
|
fi
|
||||||
|
|
||||||
e2e-live:
|
e2e-live:
|
||||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||||
@@ -292,15 +319,22 @@ jobs:
|
|||||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
python-version: '3.x'
|
||||||
|
- name: Resolve CLI E2E domains
|
||||||
|
id: e2e_domains
|
||||||
|
run: node scripts/e2e_domains.js
|
||||||
- name: Build lark-cli
|
- name: Build lark-cli
|
||||||
|
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
run: make build
|
run: make build
|
||||||
- name: Configure bot credentials
|
- name: Configure bot credentials
|
||||||
|
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||||
@@ -310,16 +344,24 @@ jobs:
|
|||||||
- name: Run CLI E2E tests
|
- name: Run CLI E2E tests
|
||||||
env:
|
env:
|
||||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||||
|
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||||
|
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||||
|
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||||
run: |
|
run: |
|
||||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
if [ "$E2E_MODE" = "skip" ]; then
|
||||||
|
echo "No live CLI E2E needed: $E2E_REASON"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
packages="$E2E_LIVE_PACKAGES"
|
||||||
if [ -z "$packages" ]; then
|
if [ -z "$packages" ]; then
|
||||||
echo "No CLI E2E packages to test after exclusions."
|
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
echo "Live CLI E2E packages: $packages"
|
||||||
|
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||||
- name: Publish CLI E2E test report
|
- name: Publish CLI E2E test report
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||||
with:
|
with:
|
||||||
name: CLI E2E Tests
|
name: CLI E2E Tests
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,6 +27,9 @@ Thumbs.db
|
|||||||
# Go
|
# Go
|
||||||
docs/ref
|
docs/ref
|
||||||
docs/
|
docs/
|
||||||
|
!tests/cli_e2e/docs/
|
||||||
|
!tests/cli_e2e/docs/*.go
|
||||||
|
!tests/cli_e2e/docs/*.md
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -2,6 +2,49 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.0.66] - 2026-07-07
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- support semantic recurring calendar operations (#1723)
|
||||||
|
- minute wait (#1768)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- guide drive import concurrency conflicts (#1751)
|
||||||
|
- **calendar**: guide approval room booking fallback (#1637)
|
||||||
|
- support pnpm global installs in self-update (#1705)
|
||||||
|
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- tighten doc creation validation workflow (#1759)
|
||||||
|
- clarify success envelope contract — judge success by ok, not code (#1730)
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- **envvars**: consolidate agent env value access (#1757)
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
|
||||||
|
|
||||||
|
## [v1.0.65] - 2026-07-03
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **drive**: Document 30-char query limit for `+search` (#1560)
|
||||||
|
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
|
||||||
|
- **doc**: Sync lark-doc skill content from online-doc (#1701)
|
||||||
|
|
||||||
## [v1.0.64] - 2026-07-02
|
## [v1.0.64] - 2026-07-02
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -1355,6 +1398,8 @@ Bundled AI agent skills for intelligent assistance:
|
|||||||
- Bilingual documentation (English & Chinese).
|
- Bilingual documentation (English & Chinese).
|
||||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||||
|
|
||||||
|
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||||
|
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
|||||||
bash scripts/resolve-changed-from.test.sh
|
bash scripts/resolve-changed-from.test.sh
|
||||||
bash scripts/ci-workflow.test.sh
|
bash scripts/ci-workflow.test.sh
|
||||||
bash scripts/semantic-review-workflow.test.sh
|
bash scripts/semantic-review-workflow.test.sh
|
||||||
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||||
|
|
||||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||||
unit-test: fetch_meta
|
unit-test: fetch_meta
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -233,6 +233,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
|||||||
--format csv # Comma-separated values
|
--format csv # Comma-separated values
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### JSON Output Contract
|
||||||
|
|
||||||
|
With `--format json` (the default), success and error envelopes are distinct.
|
||||||
|
|
||||||
|
Success goes to **stdout**, exit code `0`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors go to **stderr**, non-zero exit code:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
|
||||||
|
|
||||||
### Pagination
|
### Pagination
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
18
README.zh.md
18
README.zh.md
@@ -234,6 +234,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
|||||||
--format csv # 逗号分隔值
|
--format csv # 逗号分隔值
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### JSON 输出契约
|
||||||
|
|
||||||
|
`--format json`(默认)下,成功与错误的信封结构不同。
|
||||||
|
|
||||||
|
成功信封写入 **stdout**,退出码 0:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
错误信封写入 **stderr**,退出码非 0:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code` 和 `msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
|
||||||
|
|
||||||
### 分页
|
### 分页
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -20,13 +20,28 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
||||||
|
cmd := NewCmdApi(f, runF)
|
||||||
|
cmd.SilenceErrors = true
|
||||||
|
cmd.SilenceUsage = true
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestRootCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "lark-cli",
|
||||||
|
SilenceErrors: true,
|
||||||
|
SilenceUsage: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApiCmd_FlagParsing(t *testing.T) {
|
func TestApiCmd_FlagParsing(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||||
@@ -98,7 +113,7 @@ func TestApiCmd_BotMode(t *testing.T) {
|
|||||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET"}) // missing path
|
cmd.SetArgs([]string{"GET"}) // missing path
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
fn := cmd.ValidArgsFunction
|
fn := cmd.ValidArgsFunction
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
flag := cmd.Flags().Lookup("as")
|
flag := cmd.Flags().Lookup("as")
|
||||||
if flag == nil {
|
if flag == nil {
|
||||||
t.Fatal("expected --as flag to be registered")
|
t.Fatal("expected --as flag to be registered")
|
||||||
@@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
@@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
|||||||
ContentType: "application/octet-stream",
|
ContentType: "application/octet-stream",
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -328,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -368,7 +383,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
// Should return an error
|
// Should return an error
|
||||||
@@ -409,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -448,7 +463,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -483,7 +498,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := root.Execute()
|
err := root.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||||
@@ -791,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||||
@@ -830,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||||
@@ -859,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||||
@@ -934,7 +949,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||||
@@ -951,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||||
@@ -974,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
|||||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1015,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import (
|
|||||||
"github.com/larksuite/cli/cmd/api"
|
"github.com/larksuite/cli/cmd/api"
|
||||||
"github.com/larksuite/cli/cmd/auth"
|
"github.com/larksuite/cli/cmd/auth"
|
||||||
"github.com/larksuite/cli/cmd/service"
|
"github.com/larksuite/cli/cmd/service"
|
||||||
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/envvars"
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
"github.com/larksuite/cli/internal/meta"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/skillscheck"
|
"github.com/larksuite/cli/internal/skillscheck"
|
||||||
"github.com/larksuite/cli/internal/update"
|
"github.com/larksuite/cli/internal/update"
|
||||||
@@ -103,6 +105,11 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||||
|
t.Helper()
|
||||||
|
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||||
rootCmd.SilenceErrors = true
|
rootCmd.SilenceErrors = true
|
||||||
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
|||||||
}
|
}
|
||||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||||
service.RegisterServiceCommands(rootCmd, f)
|
if catalog != nil {
|
||||||
|
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||||
|
} else {
|
||||||
|
service.RegisterServiceCommands(rootCmd, f)
|
||||||
|
}
|
||||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||||
pruneForStrictMode(rootCmd, mode)
|
pruneForStrictMode(rootCmd, mode)
|
||||||
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
|||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func strictModeFixtureCatalog() apicatalog.Catalog {
|
||||||
|
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
|
||||||
|
{
|
||||||
|
Name: "fixture",
|
||||||
|
ServicePath: "/open-apis/fixture/v1",
|
||||||
|
Resources: map[string]meta.Resource{
|
||||||
|
"things": {
|
||||||
|
Methods: map[string]meta.Method{
|
||||||
|
"create": {
|
||||||
|
Path: "things",
|
||||||
|
HTTPMethod: "POST",
|
||||||
|
AccessTokens: []meta.Token{meta.TokenTenant},
|
||||||
|
RequestBody: map[string]meta.Field{
|
||||||
|
"name": {Type: "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
t.Setenv(envvars.CliAppID, "")
|
t.Setenv(envvars.CliAppID, "")
|
||||||
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
|||||||
|
|
||||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
catalog := strictModeFixtureCatalog()
|
||||||
|
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||||
|
|
||||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
||||||
})
|
})
|
||||||
|
|
||||||
if code != output.ExitValidation {
|
if code != output.ExitValidation {
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
|
||||||
// It uses the embedded source so completion candidates match what `schema`
|
// It uses the same source as schema execution so completion candidates match
|
||||||
// execution can resolve (both overlay-free).
|
// what `schema` can resolve.
|
||||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
mode := f.ResolveStrictMode(cmd.Context())
|
mode := f.ResolveStrictMode(cmd.Context())
|
||||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||||
directive := cobra.ShellCompDirectiveNoFileComp
|
directive := cobra.ShellCompDirectiveNoFileComp
|
||||||
if noSpace {
|
if noSpace {
|
||||||
directive |= cobra.ShellCompDirectiveNoSpace
|
directive |= cobra.ShellCompDirectiveNoSpace
|
||||||
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
|
|||||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSchema resolves the path through the embedded catalog and renders the
|
// runSchema resolves the path through the schema catalog and renders the
|
||||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||||
// output shape — a single resolved method renders as one envelope object,
|
// output shape — a single resolved method renders as one envelope object,
|
||||||
// anything broader as an array — and maps resolve failures to hints.
|
// anything broader as an array — and maps resolve failures to hints.
|
||||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||||
catalog := registry.EmbeddedCatalog()
|
catalog := registry.SchemaCatalog()
|
||||||
|
if len(catalog.Services()) == 0 {
|
||||||
|
// No embedded metadata and the runtime fallback is empty too: offline
|
||||||
|
// with a cold cache, remote meta off, or an unwritable cache dir.
|
||||||
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
|
||||||
|
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
|
||||||
|
}
|
||||||
target, err := catalog.Resolve(parts)
|
target, err := catalog.Resolve(parts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolveError(err)
|
return resolveError(err)
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
|||||||
Long: `Update lark-cli to the latest version.
|
Long: `Update lark-cli to the latest version.
|
||||||
|
|
||||||
Detects the installation method automatically:
|
Detects the installation method automatically:
|
||||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||||
|
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||||
- manual/other: shows GitHub Releases download URL
|
- manual/other: shows GitHub Releases download URL
|
||||||
|
|
||||||
Use --json for structured output (for AI agents and scripts).
|
Use --json for structured output (for AI agents and scripts).
|
||||||
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
|
|||||||
if !detect.CanAutoUpdate() {
|
if !detect.CanAutoUpdate() {
|
||||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||||
}
|
}
|
||||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Output helpers ---
|
// --- Output helpers ---
|
||||||
@@ -226,12 +227,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
|||||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
if detect.Method == selfupdate.InstallPnpm {
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||||
|
}
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||||
|
pm := "npm"
|
||||||
|
install := updater.RunNpmInstall
|
||||||
|
if detect.Method == selfupdate.InstallPnpm {
|
||||||
|
pm = "pnpm"
|
||||||
|
install = updater.RunPnpmInstall
|
||||||
|
}
|
||||||
|
|
||||||
restore, err := updater.PrepareSelfReplace()
|
restore, err := updater.PrepareSelfReplace()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return reportError(opts, io, "update_error",
|
return reportError(opts, io, "update_error",
|
||||||
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
|
||||||
}
|
}
|
||||||
|
|
||||||
npmResult := updater.RunNpmInstall(latest)
|
npmResult := install(latest)
|
||||||
if npmResult.Err != nil {
|
if npmResult.Err != nil {
|
||||||
restore()
|
restore()
|
||||||
combined := npmResult.CombinedOutput()
|
combined := npmResult.CombinedOutput()
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false, "error": map[string]interface{}{
|
"ok": false, "error": map[string]interface{}{
|
||||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
|
||||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||||
"hint": permissionHint(combined),
|
"hint": permissionHint(combined, pm),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -263,7 +275,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||||
}
|
}
|
||||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||||
if hint := permissionHint(combined); hint != "" {
|
if hint := permissionHint(combined, pm); hint != "" {
|
||||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||||
}
|
}
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -274,7 +286,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
if err := updater.VerifyBinary(latest); err != nil {
|
if err := updater.VerifyBinary(latest); err != nil {
|
||||||
restore()
|
restore()
|
||||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||||
hint := verificationFailureHint(updater, latest)
|
hint := verificationFailureHint(updater, latest, pm)
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
@@ -304,23 +316,33 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
if skillsResult != nil {
|
if skillsResult != nil {
|
||||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
skillsPM := "npx"
|
||||||
|
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
|
||||||
|
skillsPM = "pnpm dlx"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
|
||||||
}
|
}
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func permissionHint(npmOutput string) string {
|
func permissionHint(pmOutput, pm string) string {
|
||||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
||||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
return ""
|
||||||
}
|
}
|
||||||
return ""
|
if pm == "pnpm" {
|
||||||
|
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
|
||||||
|
}
|
||||||
|
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||||
}
|
}
|
||||||
|
|
||||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
|
||||||
if updater.CanRestorePreviousVersion() {
|
if updater.CanRestorePreviousVersion() {
|
||||||
return "the previous version has been restored"
|
return "the previous version has been restored"
|
||||||
}
|
}
|
||||||
|
if pm == "pnpm" {
|
||||||
|
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||||
|
}
|
||||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
|
||||||
|
// and fails the test if the npm install path is invoked.
|
||||||
|
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
|
||||||
|
t.Helper()
|
||||||
|
origNew := newUpdater
|
||||||
|
newUpdater = func() *selfupdate.Updater {
|
||||||
|
u := selfupdate.New()
|
||||||
|
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||||
|
u.PnpmInstallOverride = pnpmFn
|
||||||
|
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
|
||||||
|
t.Errorf("npm install must not be called for a pnpm install")
|
||||||
|
return &selfupdate.NpmResult{}
|
||||||
|
}
|
||||||
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
|
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||||
|
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
|
}
|
||||||
|
|
||||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||||
return func() *selfupdate.NpmResult {
|
return func() *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
@@ -81,6 +102,110 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, stdout, _ := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
)
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
|
||||||
|
t.Errorf("expected updated in output, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_Human(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, stderr := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
)
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
out := stderr.String()
|
||||||
|
if !strings.Contains(out, "via pnpm") {
|
||||||
|
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
|
||||||
|
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Successfully updated") {
|
||||||
|
t.Errorf("expected success message, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, stdout, _ := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
|
||||||
|
)
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error exit")
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
|
||||||
|
t.Errorf("expected failure envelope, got: %s", out)
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
|
||||||
|
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, stderr := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
out := stderr.String()
|
||||||
|
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
|
||||||
|
t.Errorf("expected pnpm manual reason, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "pnpm add -g") {
|
||||||
|
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeVersion(t *testing.T) {
|
func TestNormalizeVersion(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
|
|||||||
if !strings.Contains(out, "Successfully updated") {
|
if !strings.Contains(out, "Successfully updated") {
|
||||||
t.Errorf("expected success message in stderr, got: %s", out)
|
t.Errorf("expected success message in stderr, got: %s", out)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(out, "Updating skills via npx ...") {
|
||||||
|
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateForce_JSON(t *testing.T) {
|
func TestUpdateForce_JSON(t *testing.T) {
|
||||||
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
origOS := currentOS
|
origOS := currentOS
|
||||||
defer func() { currentOS = origOS }()
|
defer func() { currentOS = origOS }()
|
||||||
|
|
||||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
|
||||||
if !strings.Contains(hint, "npm global prefix") {
|
if !strings.Contains(hint, "npm global prefix") {
|
||||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||||
}
|
}
|
||||||
@@ -749,16 +877,25 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
|
||||||
|
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
|
||||||
|
if !strings.Contains(pnpmHint, "pnpm setup") {
|
||||||
|
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
|
||||||
|
}
|
||||||
|
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
|
||||||
|
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
|
||||||
|
}
|
||||||
|
|
||||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||||
currentOS = "windows"
|
currentOS = "windows"
|
||||||
hint = permissionHint("EACCES: permission denied")
|
hint = permissionHint("EACCES: permission denied", "npm")
|
||||||
if hint != "" {
|
if hint != "" {
|
||||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-EACCES error: always empty.
|
// Non-EACCES error: always empty.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
if got := permissionHint("some other error"); got != "" {
|
if got := permissionHint("some other error", "npm"); got != "" {
|
||||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,28 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
|
|||||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||||
`CategoryPolicy`.
|
`CategoryPolicy`.
|
||||||
|
|
||||||
|
### Success envelope (stdout)
|
||||||
|
|
||||||
|
For contrast: success responses render to **stdout** as an
|
||||||
|
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"identity": "user",
|
||||||
|
"data": { "guid": "e297d3d0-..." },
|
||||||
|
"meta": { "count": 1 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers must branch on `ok` (or the process exit code). The success
|
||||||
|
envelope has **no top-level `code` or `msg` field** — `code` exists only
|
||||||
|
inside `error`, where it is the upstream numeric code (invariant 4).
|
||||||
|
Wrappers that follow the raw OpenAPI convention and test `code == 0`
|
||||||
|
will misclassify every successful call as a failure, which is
|
||||||
|
especially dangerous around write commands (e.g. retrying a create that
|
||||||
|
already succeeded).
|
||||||
|
|
||||||
## Categories
|
## Categories
|
||||||
|
|
||||||
| Category | When | Exit | Typed struct |
|
| Category | When | Exit | Typed struct |
|
||||||
|
|||||||
@@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage {
|
|||||||
// space→dot fallback covers domains where the two already coincide.
|
// space→dot fallback covers domains where the two already coincide.
|
||||||
func commandFormResolver(service string) func(string) string {
|
func commandFormResolver(service string) func(string) string {
|
||||||
byForm := map[string]string{}
|
byForm := map[string]string{}
|
||||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
if svc, ok := registry.SchemaCatalog().Service(service); ok {
|
||||||
if svc.Name != service {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
}
|
||||||
return func(h string) string {
|
return func(h string) string {
|
||||||
h = strings.TrimSpace(h)
|
h = strings.TrimSpace(h)
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
@@ -40,8 +38,6 @@ const (
|
|||||||
BuildKindUnknown = "unknown"
|
BuildKindUnknown = "unknown"
|
||||||
|
|
||||||
officialModulePath = "github.com/larksuite/cli"
|
officialModulePath = "github.com/larksuite/cli"
|
||||||
|
|
||||||
agentTraceMaxLen = 1024
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||||
@@ -49,25 +45,6 @@ func UserAgentValue() string {
|
|||||||
return SourceValue + "/" + build.Version
|
return SourceValue + "/" + build.Version
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentTraceValue returns a header-safe value from the
|
|
||||||
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
|
|
||||||
// surrounding whitespace, rejects values containing any Unicode
|
|
||||||
// control character or exceeding agentTraceMaxLen, and returns ""
|
|
||||||
// for any invalid or empty value. Callers can use the result
|
|
||||||
// directly in HTTP headers without further sanitisation.
|
|
||||||
func AgentTraceValue() string {
|
|
||||||
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
|
|
||||||
if v == "" || len(v) > agentTraceMaxLen {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, r := range v {
|
|
||||||
if unicode.IsControl(r) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// BaseSecurityHeaders returns headers that every request must carry.
|
// BaseSecurityHeaders returns headers that every request must carry.
|
||||||
func BaseSecurityHeaders() http.Header {
|
func BaseSecurityHeaders() http.Header {
|
||||||
h := make(http.Header)
|
h := make(http.Header)
|
||||||
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
|
|||||||
h.Set(HeaderVersion, build.Version)
|
h.Set(HeaderVersion, build.Version)
|
||||||
h.Set(HeaderBuild, DetectBuildKind())
|
h.Set(HeaderBuild, DetectBuildKind())
|
||||||
h.Set(HeaderUserAgent, UserAgentValue())
|
h.Set(HeaderUserAgent, UserAgentValue())
|
||||||
if v := AgentTraceValue(); v != "" {
|
if v := envvars.AgentTrace(); v != "" {
|
||||||
h.Set(HeaderAgentTrace, v)
|
h.Set(HeaderAgentTrace, v)
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AgentTraceValue / HeaderAgentTrace
|
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
|
|
||||||
if got := AgentTraceValue(); got != "trace-abc-123" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
|
|
||||||
if got := AgentTraceValue(); got != "trace-trim" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, " ")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsLF(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsTab(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
|
|
||||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
|
||||||
t.Setenv(envvars.CliAgentTrace, longVal)
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
|
|
||||||
val := strings.Repeat("a", agentTraceMaxLen)
|
|
||||||
t.Setenv(envvars.CliAgentTrace, val)
|
|
||||||
if got := AgentTraceValue(); got != val {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
t.Setenv(envvars.CliAgentTrace, "")
|
||||||
h := BaseSecurityHeaders()
|
h := BaseSecurityHeaders()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
// Content safety scanning mode
|
// Content safety scanning mode
|
||||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||||
|
|
||||||
|
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||||
|
|
||||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||||
|
|||||||
36
internal/envvars/read.go
Normal file
36
internal/envvars/read.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package envvars
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
agentNameMaxLen = 128
|
||||||
|
agentTraceMaxLen = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
func AgentName() string {
|
||||||
|
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AgentTrace() string {
|
||||||
|
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSingleLine(raw string, maxLen int) string {
|
||||||
|
v := strings.TrimSpace(raw)
|
||||||
|
if v == "" || len(v) > maxLen {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range v {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
131
internal/envvars/read_test.go
Normal file
131
internal/envvars/read_test.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package envvars
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty when env unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "claude-code")
|
||||||
|
if got := AgentName(); got != "claude-code" {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, " cursor ")
|
||||||
|
if got := AgentName(); got != "cursor" {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsControlChar(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "agent\x01injected")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty for control char value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsOverlongValue(t *testing.T) {
|
||||||
|
longVal := strings.Repeat("a", agentNameMaxLen+1)
|
||||||
|
t.Setenv(CliAgentName, longVal)
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "trace-abc-123")
|
||||||
|
if got := AgentTrace(); got != "trace-abc-123" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, " trace-trim ")
|
||||||
|
if got := AgentTrace(); got != "trace-trim" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, " ")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsCRLF(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsLF(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsTab(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\tinjected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsControlChar(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\x01injected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsDEL(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\x7finjected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
|
||||||
|
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||||
|
t.Setenv(CliAgentTrace, longVal)
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
|
||||||
|
val := strings.Repeat("a", agentTraceMaxLen)
|
||||||
|
t.Setenv(CliAgentTrace, val)
|
||||||
|
if got := AgentTrace(); got != val {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,20 +10,22 @@ import "github.com/larksuite/cli/errs"
|
|||||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||||
var driveCodeMeta = map[int]CodeMeta{
|
var driveCodeMeta = map[int]CodeMeta{
|
||||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||||
|
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||||
|
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||||
|
|||||||
@@ -114,8 +114,35 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
|||||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||||
|
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||||
|
{233523001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||||
|
got, ok := LookupCodeMeta(tc.code)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||||
|
}
|
||||||
|
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||||
|
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||||
|
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupCodeMeta_WikiCodes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
code int
|
||||||
|
wantCat errs.Category
|
||||||
|
wantSubtype errs.Subtype
|
||||||
|
wantRetry bool
|
||||||
|
}{
|
||||||
|
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
|
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||||
|
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||||
|
|||||||
17
internal/errclass/codemeta_wiki.go
Normal file
17
internal/errclass/codemeta_wiki.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package errclass
|
||||||
|
|
||||||
|
import "github.com/larksuite/cli/errs"
|
||||||
|
|
||||||
|
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
|
||||||
|
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
|
||||||
|
// command-specific recovery guidance at the shortcut layer.
|
||||||
|
var wikiCodeMeta = map[int]CodeMeta{
|
||||||
|
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
|
||||||
|
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
|
||||||
|
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }
|
||||||
@@ -6,8 +6,7 @@ package registry
|
|||||||
import "github.com/larksuite/cli/internal/apicatalog"
|
import "github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
|
||||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||||
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
// metadata — deterministic across machines, for golden tests and schema lint.
|
||||||
// and schema lint.
|
|
||||||
func EmbeddedCatalog() apicatalog.Catalog {
|
func EmbeddedCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||||
}
|
}
|
||||||
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
|
|||||||
func RuntimeCatalog() apicatalog.Catalog {
|
func RuntimeCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
|
||||||
|
// otherwise the merged runtime catalog. Binaries built from the bare Go module
|
||||||
|
// embed only the empty meta_data_default.json stub, so the embedded view has
|
||||||
|
// nothing to resolve; the merged view is the only data such binaries have.
|
||||||
|
func SchemaCatalog() apicatalog.Catalog {
|
||||||
|
if len(EmbeddedServicesTyped()) > 0 {
|
||||||
|
return EmbeddedCatalog()
|
||||||
|
}
|
||||||
|
return RuntimeCatalog()
|
||||||
|
}
|
||||||
|
|||||||
67
internal/registry/catalog_test.go
Normal file
67
internal/registry/catalog_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
|
||||||
|
// restores them (with a full state reset) on cleanup.
|
||||||
|
func swapEmbeddedMeta(t *testing.T, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
resetInit()
|
||||||
|
orig := embeddedMetaJSON
|
||||||
|
embeddedMetaJSON = data
|
||||||
|
t.Cleanup(func() {
|
||||||
|
waitBackgroundRefresh()
|
||||||
|
embeddedMetaJSON = orig
|
||||||
|
resetInit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
|
||||||
|
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||||
|
|
||||||
|
c := SchemaCatalog()
|
||||||
|
|
||||||
|
if c.Source() != apicatalog.SourceEmbedded {
|
||||||
|
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
|
||||||
|
}
|
||||||
|
if _, ok := c.Service("embedded_svc"); !ok {
|
||||||
|
t.Fatal("expected embedded_svc from embedded metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
|
||||||
|
// from the bare Go module (plugin builds): only the empty meta_data_default.json
|
||||||
|
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
|
||||||
|
// Init seeds via sync fetch.
|
||||||
|
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
|
||||||
|
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write(testEnvelopeJSON("remote_svc"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
testMetaURL = ts.URL
|
||||||
|
|
||||||
|
c := SchemaCatalog()
|
||||||
|
|
||||||
|
if c.Source() != apicatalog.SourceRuntime {
|
||||||
|
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
|
||||||
|
}
|
||||||
|
if _, ok := c.Service("remote_svc"); !ok {
|
||||||
|
t.Fatal("expected remote_svc from runtime fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/meta"
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
"github.com/larksuite/cli/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed scope_priorities.json scope_overrides.json
|
//go:embed scope_priorities.json scope_overrides.json
|
||||||
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
|
|||||||
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
||||||
|
|
||||||
if !brandChanged {
|
if !brandChanged {
|
||||||
if cached, err := loadCachedMerged(); err == nil {
|
// After a CLI upgrade the embedded data can be fresher than an old
|
||||||
|
// cache; an equal/older cache must not shadow it.
|
||||||
|
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
|
||||||
overlayMergedServices(cached)
|
overlayMergedServices(cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
internal/registry/loader_test.go
Normal file
102
internal/registry/loader_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedCache writes a cache file + cache meta for one service whose Title is
|
||||||
|
// marker, tagged with the given top-level data version and brand.
|
||||||
|
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
|
||||||
|
t.Helper()
|
||||||
|
cDir := filepath.Join(dir, "cache")
|
||||||
|
if err := os.MkdirAll(cDir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := MergedRegistry{
|
||||||
|
Version: version,
|
||||||
|
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(reg)
|
||||||
|
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
|
||||||
|
mData, _ := json.Marshal(cm)
|
||||||
|
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
|
||||||
|
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
|
||||||
|
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
|
||||||
|
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
|
||||||
|
t.Helper()
|
||||||
|
embedded, _ := json.Marshal(MergedRegistry{
|
||||||
|
Version: embeddedVer,
|
||||||
|
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
|
||||||
|
})
|
||||||
|
swapEmbeddedMeta(t, embedded)
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
|
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
|
||||||
|
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
|
||||||
|
InitWithBrand(core.BrandFeishu)
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleOf(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
svc, ok := ServiceTyped(name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("service %q not loaded", name)
|
||||||
|
}
|
||||||
|
return svc.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "2.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("older cache: got %q, want EMBEDDED", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "2.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||||
|
t.Errorf("newer cache: got %q, want CACHE", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "not-a-semver")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
|
||||||
|
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
|
||||||
|
// plugin builds without compiled meta_data.json still get remote data.
|
||||||
|
initWithCache(t, "0.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||||
|
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testRegistry returns a minimal MergedRegistry with one service.
|
// testRegistry returns a minimal MergedRegistry with one service.
|
||||||
|
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
|
||||||
|
// so cache overlay passes the version gate in InitWithBrand.
|
||||||
func testRegistry(name string) MergedRegistry {
|
func testRegistry(name string) MergedRegistry {
|
||||||
return MergedRegistry{
|
return MergedRegistry{
|
||||||
Version: "test-1.0",
|
Version: "1.0.0",
|
||||||
Services: []meta.Service{
|
Services: []meta.Service{
|
||||||
{
|
{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheHit_WithinTTL(t *testing.T) {
|
func TestCacheHit_WithinTTL(t *testing.T) {
|
||||||
resetInit()
|
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNetworkError_SilentDegradation(t *testing.T) {
|
func TestNetworkError_SilentDegradation(t *testing.T) {
|
||||||
resetInit()
|
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
t.Fatal("expected non-nil data")
|
t.Fatal("expected non-nil data")
|
||||||
}
|
}
|
||||||
if reg.Version != "test-1.0" {
|
if reg.Version != "1.0.0" {
|
||||||
t.Errorf("expected version test-1.0, got %s", reg.Version)
|
t.Errorf("expected version 1.0.0, got %s", reg.Version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type InstallMethod int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
InstallNpm InstallMethod = iota
|
InstallNpm InstallMethod = iota
|
||||||
|
InstallPnpm
|
||||||
InstallManual
|
InstallManual
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,22 +54,32 @@ var (
|
|||||||
|
|
||||||
// DetectResult holds installation detection results.
|
// DetectResult holds installation detection results.
|
||||||
type DetectResult struct {
|
type DetectResult struct {
|
||||||
Method InstallMethod
|
Method InstallMethod
|
||||||
ResolvedPath string
|
ResolvedPath string
|
||||||
NpmAvailable bool
|
NpmAvailable bool
|
||||||
|
PnpmAvailable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||||
func (d DetectResult) CanAutoUpdate() bool {
|
func (d DetectResult) CanAutoUpdate() bool {
|
||||||
return d.Method == InstallNpm && d.NpmAvailable
|
switch d.Method {
|
||||||
|
case InstallNpm:
|
||||||
|
return d.NpmAvailable
|
||||||
|
case InstallPnpm:
|
||||||
|
return d.PnpmAvailable
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||||
func (d DetectResult) ManualReason() string {
|
func (d DetectResult) ManualReason() string {
|
||||||
if d.Method == InstallNpm && !d.NpmAvailable {
|
switch {
|
||||||
|
case d.Method == InstallNpm && !d.NpmAvailable:
|
||||||
return "installed via npm, but npm is not available in PATH"
|
return "installed via npm, but npm is not available in PATH"
|
||||||
|
case d.Method == InstallPnpm && !d.PnpmAvailable:
|
||||||
|
return "installed via pnpm, but pnpm is not available in PATH"
|
||||||
}
|
}
|
||||||
return "not installed via npm"
|
return "not installed via npm or pnpm"
|
||||||
}
|
}
|
||||||
|
|
||||||
// NpmResult holds the result of an npm install or skills update execution.
|
// NpmResult holds the result of an npm install or skills update execution.
|
||||||
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
|
|||||||
type Updater struct {
|
type Updater struct {
|
||||||
DetectOverride func() DetectResult
|
DetectOverride func() DetectResult
|
||||||
NpmInstallOverride func(version string) *NpmResult
|
NpmInstallOverride func(version string) *NpmResult
|
||||||
|
PnpmInstallOverride func(version string) *NpmResult
|
||||||
SkillsIndexFetchOverride func() *NpmResult
|
SkillsIndexFetchOverride func() *NpmResult
|
||||||
SkillsCommandOverride func(args ...string) *NpmResult
|
SkillsCommandOverride func(args ...string) *NpmResult
|
||||||
VerifyOverride func(expectedVersion string) error
|
VerifyOverride func(expectedVersion string) error
|
||||||
@@ -101,17 +113,38 @@ type Updater struct {
|
|||||||
// running binary is successfully renamed to .old. Used by
|
// running binary is successfully renamed to .old. Used by
|
||||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||||
backupCreated bool
|
backupCreated bool
|
||||||
|
|
||||||
|
// detectCache memoizes the first real DetectInstallMethod result. How this
|
||||||
|
// binary was installed cannot change during a single process, so caching is
|
||||||
|
// the correct semantics — and it is required for correctness: the update
|
||||||
|
// flow mutates the install (pnpm add -g / npm install -g) before syncing
|
||||||
|
// skills, so a re-detection at skills time could resolve a now-stale
|
||||||
|
// os.Executable path and misclassify. Seeded pre-update by the first call
|
||||||
|
// (updateRun), it keeps the post-update skills launcher consistent with the
|
||||||
|
// launcher reported to the user. Not goroutine-safe; the update flow is
|
||||||
|
// sequential.
|
||||||
|
detectCache *DetectResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an Updater with default (real) behavior.
|
// New creates an Updater with default (real) behavior.
|
||||||
func New() *Updater { return &Updater{} }
|
func New() *Updater { return &Updater{} }
|
||||||
|
|
||||||
// DetectInstallMethod determines how the CLI was installed and whether
|
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||||
// npm is available for auto-update.
|
// owning package manager is available for auto-update.
|
||||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||||
if u.DetectOverride != nil {
|
if u.DetectOverride != nil {
|
||||||
return u.DetectOverride()
|
return u.DetectOverride()
|
||||||
}
|
}
|
||||||
|
if u.detectCache != nil {
|
||||||
|
return *u.detectCache
|
||||||
|
}
|
||||||
|
result := u.detectInstallMethod()
|
||||||
|
u.detectCache = &result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectInstallMethod performs the real (uncached) detection.
|
||||||
|
func (u *Updater) detectInstallMethod() DetectResult {
|
||||||
exe, err := vfs.Executable()
|
exe, err := vfs.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual}
|
return DetectResult{Method: InstallManual}
|
||||||
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
||||||
}
|
}
|
||||||
|
_, npmErr := exec.LookPath("npm")
|
||||||
|
_, pnpmErr := exec.LookPath("pnpm")
|
||||||
|
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectFromResolved classifies the resolved binary path into an install
|
||||||
|
// method and records package-manager availability. Split out from
|
||||||
|
// DetectInstallMethod so the classification is unit-testable without touching
|
||||||
|
// the filesystem or PATH.
|
||||||
|
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
|
||||||
method := InstallManual
|
method := InstallManual
|
||||||
if strings.Contains(resolved, "node_modules") {
|
if strings.Contains(resolved, "node_modules") {
|
||||||
method = InstallNpm
|
if containsPnpmMarker(resolved) {
|
||||||
}
|
method = InstallPnpm
|
||||||
|
} else {
|
||||||
npmAvailable := false
|
method = InstallNpm
|
||||||
if method == InstallNpm {
|
|
||||||
if _, err := exec.LookPath("npm"); err == nil {
|
|
||||||
npmAvailable = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
d := DetectResult{Method: method, ResolvedPath: resolved}
|
||||||
return DetectResult{
|
switch method {
|
||||||
Method: method,
|
case InstallNpm:
|
||||||
ResolvedPath: resolved,
|
d.NpmAvailable = npmOnPath
|
||||||
NpmAvailable: npmAvailable,
|
case InstallPnpm:
|
||||||
|
d.PnpmAvailable = pnpmOnPath
|
||||||
}
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsPnpmMarker reports whether the resolved binary path belongs to a
|
||||||
|
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
|
||||||
|
// (a ".pnpm" directory segment) and the global content-addressable store,
|
||||||
|
// whose resolved path runs through pnpm's home directory (e.g.
|
||||||
|
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
|
||||||
|
// followed by "store". Matching only these two shapes (rather than any bare
|
||||||
|
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
|
||||||
|
// a directory named "pnpm". Windows separators are normalized to "/" so the
|
||||||
|
// classification is OS-independent and unit-testable anywhere.
|
||||||
|
func containsPnpmMarker(p string) bool {
|
||||||
|
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == ".pnpm" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||||
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
|
||||||
|
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
|
||||||
|
if u.PnpmInstallOverride != nil {
|
||||||
|
return u.PnpmInstallOverride(version)
|
||||||
|
}
|
||||||
|
r := &NpmResult{}
|
||||||
|
pnpmPath, err := exec.LookPath("pnpm")
|
||||||
|
if err != nil {
|
||||||
|
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
|
||||||
|
cmd.Stdout = &r.Stdout
|
||||||
|
cmd.Stderr = &r.Stderr
|
||||||
|
r.Err = cmd.Run()
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||||
if u.SkillsIndexFetchOverride != nil {
|
if u.SkillsIndexFetchOverride != nil {
|
||||||
return u.SkillsIndexFetchOverride()
|
return u.SkillsIndexFetchOverride()
|
||||||
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
|
|||||||
return u.runSkillsCommand(args...)
|
return u.runSkillsCommand(args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
|
||||||
|
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
|
||||||
|
// pnpm-only environments (pnpm's standalone installer bundles Node without
|
||||||
|
// putting npm/npx on PATH) can still sync skills after a self-update.
|
||||||
|
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
|
||||||
|
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
|
||||||
|
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
|
||||||
|
// launcher selection is unit-testable on any platform.
|
||||||
|
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
|
||||||
|
if method == InstallPnpm && pnpmAvailable {
|
||||||
|
r := args
|
||||||
|
if len(r) > 0 && r[0] == "-y" {
|
||||||
|
r = r[1:]
|
||||||
|
}
|
||||||
|
return "pnpm", append([]string{"dlx"}, r...)
|
||||||
|
}
|
||||||
|
return "npx", args
|
||||||
|
}
|
||||||
|
|
||||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||||
if u.SkillsCommandOverride != nil {
|
if u.SkillsCommandOverride != nil {
|
||||||
return u.SkillsCommandOverride(args...)
|
return u.SkillsCommandOverride(args...)
|
||||||
}
|
}
|
||||||
r := &NpmResult{}
|
r := &NpmResult{}
|
||||||
npxPath, err := exec.LookPath("npx")
|
det := u.DetectInstallMethod()
|
||||||
|
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
|
||||||
|
binPath, err := exec.LookPath(launcher)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
||||||
cmd.Stdout = &r.Stdout
|
cmd.Stdout = &r.Stdout
|
||||||
cmd.Stderr = &r.Stderr
|
cmd.Stderr = &r.Stderr
|
||||||
r.Err = cmd.Run()
|
r.Err = cmd.Run()
|
||||||
|
|||||||
@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
|||||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContainsPnpmMarker(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Classic virtual-store layout (.pnpm segment).
|
||||||
|
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||||
|
// Global content-addressable store layout (pnpm 11): resolved path runs
|
||||||
|
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
|
||||||
|
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||||
|
// npm and non-package installs — no pnpm/.pnpm segment.
|
||||||
|
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
{"/usr/local/bin/lark-cli", false},
|
||||||
|
// Substrings that must NOT match: segment must be exactly .pnpm, or
|
||||||
|
// "pnpm" immediately followed by "store".
|
||||||
|
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
|
||||||
|
// living under a dir named pnpm) must not be misclassified as pnpm.
|
||||||
|
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := containsPnpmMarker(c.path); got != c.want {
|
||||||
|
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectInstallMethod_Pnpm(t *testing.T) {
|
||||||
|
u := &Updater{DetectOverride: nil}
|
||||||
|
u.DetectOverride = func() DetectResult {
|
||||||
|
// Exercise the real classification by feeding a resolved path via a small shim.
|
||||||
|
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
|
||||||
|
}
|
||||||
|
got := u.DetectInstallMethod()
|
||||||
|
if got.Method != InstallPnpm {
|
||||||
|
t.Errorf("Method = %v, want InstallPnpm", got.Method)
|
||||||
|
}
|
||||||
|
if !got.PnpmAvailable {
|
||||||
|
t.Errorf("PnpmAvailable = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
|
||||||
|
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
|
||||||
|
t.Errorf("npm path Method = %v, want InstallNpm", m)
|
||||||
|
}
|
||||||
|
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
|
||||||
|
t.Errorf("manual path Method = %v, want InstallManual", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanAutoUpdate_Pnpm(t *testing.T) {
|
||||||
|
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
|
||||||
|
t.Error("pnpm available should CanAutoUpdate")
|
||||||
|
}
|
||||||
|
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
|
||||||
|
t.Error("pnpm unavailable should not CanAutoUpdate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualReason_Pnpm(t *testing.T) {
|
||||||
|
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
|
||||||
|
t.Errorf("pnpm reason = %q", got)
|
||||||
|
}
|
||||||
|
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
|
||||||
|
t.Errorf("manual reason = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPnpmInstall_Override(t *testing.T) {
|
||||||
|
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
|
||||||
|
r := &NpmResult{}
|
||||||
|
r.Stdout.WriteString("added @larksuite/cli@" + version)
|
||||||
|
return r
|
||||||
|
}}
|
||||||
|
got := u.RunPnpmInstall("2.0.0")
|
||||||
|
if got.Err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", got.Err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
|
||||||
|
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPnpmInstall_Error(t *testing.T) {
|
||||||
|
wantErr := errors.New("boom")
|
||||||
|
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
|
||||||
|
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
|
||||||
|
t.Errorf("err = %v, want %v", got.Err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsInvocation(t *testing.T) {
|
||||||
|
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
method InstallMethod
|
||||||
|
pnpmAvailable bool
|
||||||
|
args []string
|
||||||
|
wantLauncher string
|
||||||
|
wantRest []string
|
||||||
|
}{
|
||||||
|
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
|
||||||
|
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
|
||||||
|
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
|
||||||
|
"npx", addArgs},
|
||||||
|
{"npm install → npx unchanged", InstallNpm, false, addArgs,
|
||||||
|
"npx", addArgs},
|
||||||
|
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
|
||||||
|
"npx", []string{"-y", "skills", "ls", "-g"}},
|
||||||
|
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
|
||||||
|
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
|
||||||
|
if gotLauncher != c.wantLauncher {
|
||||||
|
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
|
||||||
|
}
|
||||||
|
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
|
||||||
|
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
|
||||||
|
// hazard: DetectInstallMethod must return the first (pre-update) detection on
|
||||||
|
// subsequent calls, so the skills launcher chosen after the binary is replaced
|
||||||
|
// stays consistent with what was detected — and reported — before the update.
|
||||||
|
func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||||
|
u := New()
|
||||||
|
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
|
||||||
|
u.detectCache = &cached
|
||||||
|
got := u.DetectInstallMethod()
|
||||||
|
if got.Method != InstallPnpm || !got.PnpmAvailable {
|
||||||
|
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func safePath(raw, flagName string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isAbsolutePath(raw) {
|
if isAbsolutePath(raw) {
|
||||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: use a relative path like ./filename; flags that support stdin can read an out-of-tree file via '-' instead)", flagName, raw)
|
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
path := filepath.Clean(raw)
|
path := filepath.Clean(raw)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@larksuite/cli",
|
"name": "@larksuite/cli",
|
||||||
"version": "1.0.64",
|
"version": "1.0.66",
|
||||||
"description": "The official CLI for Lark/Feishu open platform",
|
"description": "The official CLI for Lark/Feishu open platform",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lark-cli": "scripts/run.js"
|
"lark-cli": "scripts/run.js"
|
||||||
|
|||||||
@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
|
||||||
|
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
|
||||||
|
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
|
||||||
|
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
|
||||||
|
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should explicitly skip when domain mode is skip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
|
||||||
|
! grep -Fq "id: e2e_domains" <<<"$section" ||
|
||||||
|
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
|
||||||
|
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
|
||||||
|
echo "e2e-live should use resolved live_packages instead of always running the full suite"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
|
||||||
|
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
|
||||||
|
echo "e2e-live should pass dynamic domain output through env before shell use"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! awk '
|
||||||
|
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||||
|
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||||
|
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||||
|
END { exit found ? 0 : 1 }
|
||||||
|
' <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! awk '
|
||||||
|
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||||
|
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||||
|
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||||
|
END { exit found ? 0 : 1 }
|
||||||
|
' <<<"$section"; then
|
||||||
|
echo "e2e-live should skip building lark-cli when domain mode is skip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if ! grep -Fq "permissions:" <<<"$section" ||
|
if ! grep -Fq "permissions:" <<<"$section" ||
|
||||||
! grep -Fq "contents: read" <<<"$section" ||
|
! grep -Fq "contents: read" <<<"$section" ||
|
||||||
! grep -Fq "checks: write" <<<"$section"; then
|
! grep -Fq "checks: write" <<<"$section"; then
|
||||||
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! awk '
|
||||||
|
/^ - name: Configure bot credentials/ { in_step = 1 }
|
||||||
|
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||||
|
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
|
||||||
|
END { exit found ? 0 : 1 }
|
||||||
|
' <<<"$section"; then
|
||||||
|
echo "e2e-live should only configure bot credentials when domain mode is not skip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
||||||
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
|
||||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
54
scripts/domain-map.js
Normal file
54
scripts/domain-map.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
|
||||||
|
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
|
||||||
|
|
||||||
|
function normalizeRepoPath(input) {
|
||||||
|
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
|
||||||
|
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
|
||||||
|
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||||
|
|
||||||
|
function findPathMapping(filePath) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelDomainsForPath(filePath) {
|
||||||
|
const mapping = findPathMapping(filePath);
|
||||||
|
return mapping ? [...(mapping.labelDomains || [])] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function e2eDomainsForPath(filePath) {
|
||||||
|
const mapping = findPathMapping(filePath);
|
||||||
|
return mapping ? [...(mapping.e2eDomains || [])] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFullFallback(filePath) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSkippablePath(filePath) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
const basename = path.posix.basename(normalized);
|
||||||
|
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|
||||||
|
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|
||||||
|
|| (domainMap.skipFilenames || []).includes(basename);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
domainMap,
|
||||||
|
e2eDomainsForPath,
|
||||||
|
findPathMapping,
|
||||||
|
isSkippablePath,
|
||||||
|
labelDomainsForPath,
|
||||||
|
matchesFullFallback,
|
||||||
|
normalizeRepoPath,
|
||||||
|
};
|
||||||
71
scripts/domain-map.json
Normal file
71
scripts/domain-map.json
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"pathMappings": [
|
||||||
|
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||||
|
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||||
|
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||||
|
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||||
|
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||||
|
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||||
|
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||||
|
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||||
|
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||||
|
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||||
|
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||||
|
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||||
|
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||||
|
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||||
|
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||||
|
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||||
|
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||||
|
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
|
||||||
|
|
||||||
|
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||||
|
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||||
|
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||||
|
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||||
|
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||||
|
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||||
|
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||||
|
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||||
|
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||||
|
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||||
|
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||||
|
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||||
|
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||||
|
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||||
|
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||||
|
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||||
|
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||||
|
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
|
||||||
|
],
|
||||||
|
"fullFallbackPrefixes": [
|
||||||
|
"shortcuts/common/",
|
||||||
|
"cmd/",
|
||||||
|
"internal/",
|
||||||
|
"pkg/",
|
||||||
|
"extension/",
|
||||||
|
"registry/",
|
||||||
|
"go.mod",
|
||||||
|
"go.sum",
|
||||||
|
"Makefile",
|
||||||
|
".github/workflows/",
|
||||||
|
"scripts/"
|
||||||
|
],
|
||||||
|
"skipPrefixes": [
|
||||||
|
"docs/",
|
||||||
|
".changeset/"
|
||||||
|
],
|
||||||
|
"skipSuffixes": [
|
||||||
|
".md",
|
||||||
|
".mdx",
|
||||||
|
".txt",
|
||||||
|
".rst"
|
||||||
|
],
|
||||||
|
"skipFilenames": [
|
||||||
|
"readme.md",
|
||||||
|
"readme.zh.md",
|
||||||
|
"changelog.md",
|
||||||
|
"license",
|
||||||
|
"cla.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
224
scripts/e2e_domains.js
Normal file
224
scripts/e2e_domains.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const {
|
||||||
|
e2eDomainsForPath,
|
||||||
|
findPathMapping,
|
||||||
|
isSkippablePath,
|
||||||
|
matchesFullFallback,
|
||||||
|
normalizeRepoPath,
|
||||||
|
} = require("./domain-map");
|
||||||
|
|
||||||
|
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
|
||||||
|
process.chdir(ROOT);
|
||||||
|
|
||||||
|
function execLines(command, args) {
|
||||||
|
return execFileSync(command, args, { encoding: "utf8" })
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modulePath() {
|
||||||
|
return execLines("go", ["list", "-m"])[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootPackage(moduleName) {
|
||||||
|
return `${moduleName}/tests/cli_e2e`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allLivePackages(moduleName) {
|
||||||
|
return execLines("go", ["list", "./tests/cli_e2e/..."])
|
||||||
|
.filter((pkg) => pkg !== rootPackage(moduleName))
|
||||||
|
.filter((pkg) => !pkg.endsWith("/demo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function allDryPackages(moduleName) {
|
||||||
|
return allLivePackages(moduleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainExistsCache = new Map();
|
||||||
|
|
||||||
|
function domainExists(domain) {
|
||||||
|
if (domainExistsCache.has(domain)) {
|
||||||
|
return domainExistsCache.get(domain);
|
||||||
|
}
|
||||||
|
let exists = false;
|
||||||
|
try {
|
||||||
|
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
|
||||||
|
exists = true;
|
||||||
|
} catch {
|
||||||
|
exists = false;
|
||||||
|
}
|
||||||
|
domainExistsCache.set(domain, exists);
|
||||||
|
return exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChangedFiles() {
|
||||||
|
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
|
||||||
|
if (changedFilesPath) {
|
||||||
|
return fs.readFileSync(changedFilesPath, "utf8")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(normalizeRepoPath)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseRef = process.env.GITHUB_BASE_REF || "main";
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
|
||||||
|
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDomain(domains, domain) {
|
||||||
|
if (domain && domainExists(domain)) {
|
||||||
|
domains.add(domain);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyPath(filePath, domains) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
if (!normalized) return { matched: false };
|
||||||
|
|
||||||
|
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
|
||||||
|
if (e2eMatch) {
|
||||||
|
const domain = e2eMatch[1];
|
||||||
|
if (domain === "demo") return { matched: false };
|
||||||
|
if (domainExists(domain)) {
|
||||||
|
addDomain(domains, domain);
|
||||||
|
return { matched: true };
|
||||||
|
}
|
||||||
|
if (isSkippablePath(normalized)) return { matched: false };
|
||||||
|
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("tests/cli_e2e/")) {
|
||||||
|
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesFullFallback(normalized)) {
|
||||||
|
return { fullReason: `shared/runtime path changed: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedDomains = e2eDomainsForPath(normalized);
|
||||||
|
if (mappedDomains.length > 0) {
|
||||||
|
const missingDomains = [];
|
||||||
|
for (const domain of mappedDomains) {
|
||||||
|
if (!addDomain(domains, domain)) missingDomains.push(domain);
|
||||||
|
}
|
||||||
|
if (missingDomains.length > 0) {
|
||||||
|
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
|
||||||
|
}
|
||||||
|
return { matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findPathMapping(normalized)) {
|
||||||
|
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
|
||||||
|
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSkippablePath(normalized)) return { matched: false };
|
||||||
|
|
||||||
|
return { fullReason: `unclassified path changed: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDomains(changedFiles) {
|
||||||
|
const moduleName = modulePath();
|
||||||
|
const rootDryPackage = rootPackage(moduleName);
|
||||||
|
if (changedFiles === null) {
|
||||||
|
return {
|
||||||
|
mode: "full",
|
||||||
|
reason: "non-pull_request run or unavailable diff",
|
||||||
|
domains: ["all"],
|
||||||
|
dryRootPackage: rootDryPackage,
|
||||||
|
dryPackages: allDryPackages(moduleName),
|
||||||
|
livePackages: allLivePackages(moduleName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = new Set();
|
||||||
|
let matchedRelevant = false;
|
||||||
|
let fullReason = "";
|
||||||
|
|
||||||
|
for (const file of changedFiles) {
|
||||||
|
const result = classifyPath(file, domains);
|
||||||
|
if (result.matched) matchedRelevant = true;
|
||||||
|
if (result.fullReason && !fullReason) fullReason = result.fullReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullReason) {
|
||||||
|
return {
|
||||||
|
mode: "full",
|
||||||
|
reason: fullReason,
|
||||||
|
domains: ["all"],
|
||||||
|
dryRootPackage: rootDryPackage,
|
||||||
|
dryPackages: allDryPackages(moduleName),
|
||||||
|
livePackages: allLivePackages(moduleName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedRelevant && domains.size > 0) {
|
||||||
|
const sortedDomains = [...domains].sort();
|
||||||
|
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
|
||||||
|
return {
|
||||||
|
mode: "subset",
|
||||||
|
reason: "business domain changes",
|
||||||
|
domains: sortedDomains,
|
||||||
|
dryRootPackage: rootDryPackage,
|
||||||
|
dryPackages: packages,
|
||||||
|
livePackages: packages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "skip",
|
||||||
|
reason: "docs-only or no live CLI E2E impact",
|
||||||
|
domains: [],
|
||||||
|
dryRootPackage: "",
|
||||||
|
dryPackages: [],
|
||||||
|
livePackages: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(resolved) {
|
||||||
|
const values = {
|
||||||
|
mode: resolved.mode,
|
||||||
|
reason: resolved.reason,
|
||||||
|
domains: resolved.domains.join(","),
|
||||||
|
dry_root_package: resolved.dryRootPackage,
|
||||||
|
dry_packages: resolved.dryPackages.join(" "),
|
||||||
|
live_packages: resolved.livePackages.join(" "),
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
||||||
|
console.log(lines.join("\n"));
|
||||||
|
|
||||||
|
if (process.env.GITHUB_OUTPUT) {
|
||||||
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
emit(resolveDomains(readChangedFiles()));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
classifyPath,
|
||||||
|
readChangedFiles,
|
||||||
|
resolveDomains,
|
||||||
|
};
|
||||||
94
scripts/e2e_domains.test.js
Normal file
94
scripts/e2e_domains.test.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const test = require("node:test");
|
||||||
|
|
||||||
|
const scriptPath = path.join(__dirname, "e2e_domains.js");
|
||||||
|
|
||||||
|
function parseOutput(raw) {
|
||||||
|
const result = {};
|
||||||
|
for (const line of raw.trim().split(/\r?\n/)) {
|
||||||
|
const idx = line.indexOf("=");
|
||||||
|
if (idx === -1) continue;
|
||||||
|
result[line.slice(0, idx)] = line.slice(idx + 1);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDomains(files) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
|
||||||
|
const file = path.join(dir, "changed.txt");
|
||||||
|
fs.writeFileSync(file, `${files.join("\n")}\n`);
|
||||||
|
try {
|
||||||
|
return parseOutput(execFileSync(process.execPath, [scriptPath], {
|
||||||
|
cwd: path.join(__dirname, ".."),
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("maps shortcut changes to one business domain package", () => {
|
||||||
|
const output = runDomains(["shortcuts/im/messages/send.go"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "im");
|
||||||
|
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
|
||||||
|
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps doc shortcuts to docs package", () => {
|
||||||
|
const output = runDomains(["shortcuts/doc/update.go"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "docs");
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps direct e2e domain package changes", () => {
|
||||||
|
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "drive");
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to full for shared e2e harness changes", () => {
|
||||||
|
const output = runDomains(["tests/cli_e2e/core.go"]);
|
||||||
|
assert.equal(output.mode, "full");
|
||||||
|
assert.equal(output.domains, "all");
|
||||||
|
assert.match(output.reason, /shared CLI E2E harness changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to full for runtime changes", () => {
|
||||||
|
const output = runDomains(["cmd/root.go"]);
|
||||||
|
assert.equal(output.mode, "full");
|
||||||
|
assert.equal(output.domains, "all");
|
||||||
|
assert.match(output.reason, /shared\/runtime path changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips docs-only changes", () => {
|
||||||
|
const output = runDomains(["docs/usage.md", "README.md"]);
|
||||||
|
assert.equal(output.mode, "skip");
|
||||||
|
assert.equal(output.domains, "");
|
||||||
|
assert.equal(output.dry_root_package, "");
|
||||||
|
assert.equal(output.live_packages, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses shared map for skill domain changes", () => {
|
||||||
|
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "sheets");
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to full when a mapped path has no e2e package", () => {
|
||||||
|
const output = runDomains(["shortcuts/whiteboard/export.go"]);
|
||||||
|
assert.equal(output.mode, "full");
|
||||||
|
assert.match(output.reason, /unmapped CLI E2E domain path/);
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
const fs = require("node:fs/promises");
|
const fs = require("node:fs/promises");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
const { labelDomainsForPath } = require("../domain-map");
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Constants & Configuration
|
// Constants & Configuration
|
||||||
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
|
|||||||
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
||||||
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
||||||
|
|
||||||
// CODEOWNERS-based path to domain label mapping
|
|
||||||
// Maps shortcuts and skills paths to business domain labels
|
|
||||||
const PATH_TO_DOMAIN_MAP = {
|
|
||||||
// shortcuts
|
|
||||||
"shortcuts/im/": "im",
|
|
||||||
"shortcuts/vc/": "vc",
|
|
||||||
"shortcuts/calendar/": "calendar",
|
|
||||||
"shortcuts/doc/": "ccm",
|
|
||||||
"shortcuts/sheets/": "ccm",
|
|
||||||
"shortcuts/drive/": "ccm",
|
|
||||||
"shortcuts/wiki/": "ccm",
|
|
||||||
"shortcuts/base/": "base",
|
|
||||||
"shortcuts/mail/": "mail",
|
|
||||||
"shortcuts/task/": "task",
|
|
||||||
"shortcuts/contact/": "contact",
|
|
||||||
// skills
|
|
||||||
"skills/lark-im/": "im",
|
|
||||||
"skills/lark-vc/": "vc",
|
|
||||||
"skills/lark-doc/": "ccm",
|
|
||||||
"skills/lark-wiki/": "ccm",
|
|
||||||
"skills/lark-base/": "base",
|
|
||||||
"skills/lark-mail/": "mail",
|
|
||||||
"skills/lark-calendar/": "calendar",
|
|
||||||
"skills/lark-task/": "task",
|
|
||||||
"skills/lark-contact/": "contact",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
||||||
|
|
||||||
const CLASS_STANDARDS = {
|
const CLASS_STANDARDS = {
|
||||||
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
|
|||||||
|
|
||||||
// Get business domain label based on CODEOWNERS path mapping
|
// Get business domain label based on CODEOWNERS path mapping
|
||||||
function getBusinessDomain(filePath) {
|
function getBusinessDomain(filePath) {
|
||||||
const normalized = normalizePath(filePath);
|
return labelDomainsForPath(filePath)[0] || "";
|
||||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
|
||||||
if (normalized.startsWith(prefix)) {
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectNewShortcutDomain(files) {
|
async function detectNewShortcutDomain(files) {
|
||||||
|
|||||||
@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||||||
script="$repo_root/scripts/resolve-changed-from.sh"
|
script="$repo_root/scripts/resolve-changed-from.sh"
|
||||||
|
|
||||||
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
|
||||||
|
cleanup_tmp() {
|
||||||
|
local attempt
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
rm -rf "$tmp" && return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup_tmp EXIT
|
||||||
mkdir -p "$tmp"
|
mkdir -p "$tmp"
|
||||||
|
|
||||||
git_init() {
|
git_init() {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
|
|||||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -145,7 +145,10 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
|||||||
existing := map[string]bool{}
|
existing := map[string]bool{}
|
||||||
token := ""
|
token := ""
|
||||||
for {
|
for {
|
||||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
params := map[string]interface{}{"page_size": 100}
|
||||||
|
if env != "" {
|
||||||
|
params["env"] = env
|
||||||
|
}
|
||||||
if token != "" {
|
if token != "" {
|
||||||
params["page_token"] = token
|
params["page_token"] = token
|
||||||
}
|
}
|
||||||
@@ -168,7 +171,11 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
|||||||
|
|
||||||
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
||||||
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
|
statusParams := map[string]interface{}{}
|
||||||
|
if env != "" {
|
||||||
|
statusParams["env"] = env
|
||||||
|
}
|
||||||
|
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -208,11 +215,10 @@ func auditListTables(rctx *common.RuntimeContext) []string {
|
|||||||
|
|
||||||
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
||||||
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
||||||
params := map[string]interface{}{
|
params := dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"tables": strings.Join(tables, ","),
|
"tables": strings.Join(tables, ","),
|
||||||
"page_size": rctx.Int("page-size"),
|
"page_size": rctx.Int("page-size"),
|
||||||
}
|
})
|
||||||
addStr := func(flag, key string) {
|
addStr := func(flag, key string) {
|
||||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||||
params[key] = v
|
params[key] = v
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
|||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Desc: "table to enable audit for", Required: true},
|
{Name: "table", Desc: "table to enable audit for", Required: true},
|
||||||
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
POST(appAuditSetPath(appID)).
|
POST(appAuditSetPath(appID)).
|
||||||
Desc("Enable table audit").
|
Desc("Enable table audit").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
|
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -60,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
|||||||
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
||||||
defer stop()
|
defer stop()
|
||||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||||
map[string]interface{}{"env": dbEnv(rctx)},
|
dbEnvParams(rctx, map[string]interface{}{}),
|
||||||
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
||||||
stop()
|
stop()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
|||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Desc: "table to disable audit for", Required: true},
|
{Name: "table", Desc: "table to disable audit for", Required: true},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
POST(appAuditSetPath(appID)).
|
POST(appAuditSetPath(appID)).
|
||||||
Desc("Disable table audit").
|
Desc("Disable table audit").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
|||||||
}
|
}
|
||||||
table := strings.TrimSpace(rctx.Str("table"))
|
table := strings.TrimSpace(rctx.Str("table"))
|
||||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||||
map[string]interface{}{"env": dbEnv(rctx)},
|
dbEnvParams(rctx, map[string]interface{}{}),
|
||||||
map[string]interface{}{"table": table, "enabled": false})
|
map[string]interface{}{"table": table, "enabled": false})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(err, dbAuditSetHint)
|
return withAppsHint(err, dbAuditSetHint)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
|||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
|||||||
|
|
||||||
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
||||||
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||||
params["table"] = t
|
params["table"] = t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
|
|||||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -77,10 +77,9 @@ var AppsDBChangelogList = common.Shortcut{
|
|||||||
|
|
||||||
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
||||||
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{
|
params := dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"page_size": rctx.Int("page-size"),
|
"page_size": rctx.Int("page-size"),
|
||||||
}
|
})
|
||||||
addStr := func(flag, key string) {
|
addStr := func(flag, key string) {
|
||||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||||
params[key] = v
|
params[key] = v
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
{Name: "table", Desc: "source table", Required: true},
|
{Name: "table", Desc: "source table", Required: true},
|
||||||
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
||||||
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
GET(appDataExportPath(appID)).
|
GET(appDataExportPath(appID)).
|
||||||
Desc("Export Miaoda app table data (raw bytes)").
|
Desc("Export Miaoda app table data (raw bytes)").
|
||||||
Params(map[string]interface{}{
|
Params(dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
|
"table": strings.TrimSpace(rctx.Str("table")),
|
||||||
"format": format, "limit": rctx.Int("limit"),
|
"format": format, "limit": rctx.Int("limit"),
|
||||||
})
|
}))
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
appID, err := requireAppID(rctx.Str("app-id"))
|
appID, err := requireAppID(rctx.Str("app-id"))
|
||||||
@@ -95,15 +95,18 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
||||||
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
||||||
|
|
||||||
|
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{
|
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||||
HttpMethod: http.MethodGet,
|
HttpMethod: http.MethodGet,
|
||||||
ApiPath: appDataExportPath(appID),
|
ApiPath: appDataExportPath(appID),
|
||||||
QueryParams: larkcore.QueryParams{
|
QueryParams: exportQuery,
|
||||||
"env": []string{dbEnv(rctx)},
|
|
||||||
"table": []string{table},
|
|
||||||
"format": []string{format},
|
|
||||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
||||||
@@ -157,8 +160,11 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
||||||
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
||||||
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
||||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
|
params := map[string]interface{}{"page_size": 1}
|
||||||
map[string]interface{}{"env": env, "page_size": 1}, nil)
|
if env != "" {
|
||||||
|
params["env"] = env
|
||||||
|
}
|
||||||
|
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
|
|||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
|
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
|
||||||
{Name: "table", Desc: "target table (default: file name without extension)"},
|
{Name: "table", Desc: "target table (default: file name without extension)"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
POST(appDataImportPath(appID)).
|
POST(appDataImportPath(appID)).
|
||||||
Desc("Import data file into Miaoda app table (multipart upload)").
|
Desc("Import data file into Miaoda app table (multipart upload)").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
|
Params(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
|
||||||
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -100,10 +100,14 @@ var AppsDBDataImport = common.Shortcut{
|
|||||||
fd.AddField("file_name", fileName)
|
fd.AddField("file_name", fileName)
|
||||||
fd.AddFile("file", bytes.NewReader(content))
|
fd.AddFile("file", bytes.NewReader(content))
|
||||||
|
|
||||||
|
importQuery := larkcore.QueryParams{"table": []string{table}}
|
||||||
|
if env := dbEnv(rctx); env != "" {
|
||||||
|
importQuery["env"] = []string{env}
|
||||||
|
}
|
||||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||||
HttpMethod: http.MethodPost,
|
HttpMethod: http.MethodPost,
|
||||||
ApiPath: appDataImportPath(appID),
|
ApiPath: appDataImportPath(appID),
|
||||||
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
|
QueryParams: importQuery,
|
||||||
Body: fd,
|
Body: fd,
|
||||||
}, larkcore.WithFileUpload())
|
}, larkcore.WithFileUpload())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -121,6 +121,31 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAppsDBDataImport_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 dry-run 的 query
|
||||||
|
// 不带 env 键(交服务端按应用形态自动选分支),但仍携带 table。
|
||||||
|
func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||||
|
chdirTemp(t)
|
||||||
|
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||||
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
|
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||||
|
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("dry-run err=%v", err)
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
API []struct {
|
||||||
|
Params map[string]interface{} `json:"params"`
|
||||||
|
} `json:"api"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||||
|
p := env.API[0].Params
|
||||||
|
if _, ok := p["env"]; ok {
|
||||||
|
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
|
||||||
|
}
|
||||||
|
if p["table"] != "orders" {
|
||||||
|
t.Fatalf("table should still default to file basename, got params=%v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
||||||
func TestAppsDBDataImport_Success(t *testing.T) {
|
func TestAppsDBDataImport_Success(t *testing.T) {
|
||||||
chdirTemp(t)
|
chdirTemp(t)
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ var AppsDBEnvMigrate = common.Shortcut{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)")
|
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||||||
defer stop()
|
defer stop()
|
||||||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
||||||
@@ -104,6 +114,12 @@ var AppsDBEnvMigrate = common.Shortcut{
|
|||||||
return withAppsHint(err, dbEnvMigrateHint)
|
return withAppsHint(err, dbEnvMigrateHint)
|
||||||
}
|
}
|
||||||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||||||
|
if from == "" {
|
||||||
|
from = previewFrom
|
||||||
|
}
|
||||||
|
if to == "" {
|
||||||
|
to = previewTo
|
||||||
|
}
|
||||||
taskID := common.GetString(submit, "task_id")
|
taskID := common.GetString(submit, "task_id")
|
||||||
applied := intFromAny(submit["changes_applied"])
|
applied := intFromAny(submit["changes_applied"])
|
||||||
if applied == 0 {
|
if applied == 0 {
|
||||||
@@ -131,6 +147,10 @@ var AppsDBEnvMigrate = common.Shortcut{
|
|||||||
applied = n
|
applied = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
|
||||||
|
if applied == 0 && pending > 0 {
|
||||||
|
applied = pending
|
||||||
|
}
|
||||||
stop() // clear spinner before printing the result
|
stop() // clear spinner before printing the result
|
||||||
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
||||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||||
|
|||||||
@@ -105,8 +105,10 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
|||||||
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
||||||
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||||
|
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: "POST", URL: dbEnvMigrateURL,
|
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||||
})
|
})
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
@@ -126,8 +128,10 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
|||||||
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
||||||
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
||||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||||
|
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: "POST", URL: dbEnvMigrateURL,
|
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||||
})
|
})
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
@@ -319,6 +323,31 @@ func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
// 配额未对接(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) {
|
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
||||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
reg.Register(&httpmock.Stub{
|
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",
|
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
||||||
Input: []string{common.Stdin}},
|
Input: []string{common.Stdin}},
|
||||||
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -291,10 +291,9 @@ func parseErrorSentinel(data string) (int, string) {
|
|||||||
//
|
//
|
||||||
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
||||||
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
return map[string]interface{}{
|
return dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"transactional": false,
|
"transactional": false,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
|
|||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -41,14 +41,14 @@ var AppsDBQuotaGet = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
GET(appDbQuotaPath(appID)).
|
GET(appDbQuotaPath(appID)).
|
||||||
Desc("Get Miaoda app database storage usage").
|
Desc("Get Miaoda app database storage usage").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx)})
|
Params(dbEnvParams(rctx, map[string]interface{}{}))
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
appID, err := requireAppID(rctx.Str("app-id"))
|
appID, err := requireAppID(rctx.Str("app-id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
|
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(err, appIDListHint)
|
return withAppsHint(err, appIDListHint)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,19 +32,23 @@ var AppsDBRecoveryDiff = common.Shortcut{
|
|||||||
Scopes: []string{"spark:app:write"},
|
Scopes: []string{"spark:app:write"},
|
||||||
AuthTypes: []string{"user"},
|
AuthTypes: []string{"user"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", 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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return normalizeTimeFlags(rctx, "target")
|
return normalizeTimeFlags(rctx, "target")
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
|
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})
|
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -81,19 +85,23 @@ var AppsDBRecoveryApply = common.Shortcut{
|
|||||||
Scopes: []string{"spark:app:write"},
|
Scopes: []string{"spark:app:write"},
|
||||||
AuthTypes: []string{"user"},
|
AuthTypes: []string{"user"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", 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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return normalizeTimeFlags(rctx, "target")
|
return normalizeTimeFlags(rctx, "target")
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
|
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})
|
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -104,7 +112,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
|||||||
target := rctx.Str("target")
|
target := rctx.Str("target")
|
||||||
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
||||||
defer stop()
|
defer stop()
|
||||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
|
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(err, dbRecoveryHint)
|
return withAppsHint(err, dbRecoveryHint)
|
||||||
}
|
}
|
||||||
@@ -119,7 +127,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
|||||||
}
|
}
|
||||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
||||||
func() (map[string]interface{}, error) {
|
func() (map[string]interface{}, error) {
|
||||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||||
},
|
},
|
||||||
func(d map[string]interface{}) (bool, error) {
|
func(d map[string]interface{}) (bool, error) {
|
||||||
switch strings.ToLower(common.GetString(d, "status")) {
|
switch strings.ToLower(common.GetString(d, "status")) {
|
||||||
@@ -157,7 +165,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
|||||||
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
||||||
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
||||||
defer stop()
|
defer stop()
|
||||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
|
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, withAppsHint(err, dbRecoveryHint)
|
return nil, withAppsHint(err, dbRecoveryHint)
|
||||||
}
|
}
|
||||||
@@ -167,7 +175,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
|
|||||||
}
|
}
|
||||||
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||||||
func() (map[string]interface{}, error) {
|
func() (map[string]interface{}, error) {
|
||||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil)
|
||||||
},
|
},
|
||||||
func(d map[string]interface{}) (bool, error) {
|
func(d map[string]interface{}) (bool, error) {
|
||||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||||
@@ -195,13 +203,13 @@ type recoveryChange struct {
|
|||||||
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
||||||
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
||||||
arr, _ := preview["changes"].([]interface{})
|
arr, _ := preview["changes"].([]interface{})
|
||||||
changes := make([]recoveryChange, 0, len(arr))
|
raw := make([]recoveryChange, 0, len(arr))
|
||||||
for _, it := range arr {
|
for _, it := range arr {
|
||||||
m, ok := it.(map[string]interface{})
|
m, ok := it.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
changes = append(changes, recoveryChange{
|
raw = append(raw, recoveryChange{
|
||||||
Table: common.GetString(m, "table"),
|
Table: common.GetString(m, "table"),
|
||||||
Inserted: m["inserted"],
|
Inserted: m["inserted"],
|
||||||
Deleted: m["deleted"],
|
Deleted: m["deleted"],
|
||||||
@@ -209,16 +217,33 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
|
|||||||
DroppedAt: common.GetString(m, "dropped_at"),
|
DroppedAt: common.GetString(m, "dropped_at"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
tablesAffected := intFromAny(preview["tables_affected"])
|
// 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。
|
||||||
if tablesAffected == 0 {
|
// schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表
|
||||||
tablesAffected = len(changes)
|
// 两行 + tables_affected 翻倍。
|
||||||
|
hasSchema := map[string]bool{}
|
||||||
|
for _, c := range raw {
|
||||||
|
if c.Action != "" {
|
||||||
|
hasSchema[c.Table] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changes := make([]recoveryChange, 0, len(raw))
|
||||||
|
for _, c := range raw {
|
||||||
|
if c.Action == "" && hasSchema[c.Table] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changes = append(changes, c)
|
||||||
|
}
|
||||||
|
// tables_affected 按去重后的不同表数计(而非变更条数)。
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, c := range changes {
|
||||||
|
seen[c.Table] = true
|
||||||
}
|
}
|
||||||
est := intFromAny(preview["estimated_seconds"])
|
est := intFromAny(preview["estimated_seconds"])
|
||||||
if est == 0 {
|
if est == 0 {
|
||||||
est = 30 // PRD 兜底
|
est = 30 // PRD 兜底
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"target": target, "tables_affected": tablesAffected,
|
"target": target, "tables_affected": len(seen),
|
||||||
"changes": changes, "estimated_seconds": est,
|
"changes": changes, "estimated_seconds": est,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
|
|||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "app id", Required: true},
|
{Name: "app-id", Desc: "app id", Required: true},
|
||||||
{Name: "table", Desc: "table name", Required: true},
|
{Name: "table", Desc: "table name", Required: true},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -80,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{
|
|||||||
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本;
|
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本;
|
||||||
// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。
|
// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。
|
||||||
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||||
if rctx.Format == "pretty" {
|
if rctx.Format == "pretty" {
|
||||||
params["format"] = "ddl"
|
params["format"] = "ddl"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -42,7 +43,7 @@ var AppsDBTableList = common.Shortcut{
|
|||||||
{Name: "app-id", Desc: "app id", Required: true},
|
{Name: "app-id", Desc: "app id", Required: true},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -110,10 +111,9 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{
|
params := dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"page_size": rctx.Int("page-size"),
|
"page_size": rctx.Int("page-size"),
|
||||||
}
|
})
|
||||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||||
params["page_token"] = token
|
params["page_token"] = token
|
||||||
}
|
}
|
||||||
@@ -286,6 +286,17 @@ func numericAsFloat(raw interface{}) (float64, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
return f, true
|
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:
|
case nil:
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,7 +236,11 @@ func TestNumericAsFloat_AllTypes(t *testing.T) {
|
|||||||
{"json.Number valid", json.Number("13.5"), 13.5, true},
|
{"json.Number valid", json.Number("13.5"), 13.5, true},
|
||||||
{"json.Number invalid", json.Number("abc"), 0, false},
|
{"json.Number invalid", json.Number("abc"), 0, false},
|
||||||
{"nil", nil, 0, false},
|
{"nil", nil, 0, false},
|
||||||
{"unsupported string", "x", 0, false},
|
{"non-numeric string", "x", 0, false},
|
||||||
|
{"numeric string", "13.5", 13.5, true},
|
||||||
|
{"numeric string int", "2", 2, true},
|
||||||
|
{"numeric string padded", " 13.5 ", 13.5, true},
|
||||||
|
{"empty string", "", 0, false},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ func dbEnv(rctx *common.RuntimeContext) string {
|
|||||||
return rctx.Str("environment")
|
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。
|
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env:显式传了就报清晰的 validation 错,指向 --environment。
|
||||||
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
|
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
|
||||||
if rctx.Changed("env") {
|
if rctx.Changed("env") {
|
||||||
|
|||||||
@@ -67,6 +67,26 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
|||||||
return attendees, nil
|
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{
|
var CalendarCreate = common.Shortcut{
|
||||||
Service: "calendar",
|
Service: "calendar",
|
||||||
Command: "+create",
|
Command: "+create",
|
||||||
@@ -225,6 +245,7 @@ var CalendarCreate = common.Shortcut{
|
|||||||
"need_notification": true,
|
"need_notification": true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
err = guideApprovalRoomReasonError(err, attendees)
|
||||||
// Rollback: delete the event
|
// Rollback: delete the event
|
||||||
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
||||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||||
@@ -285,6 +306,9 @@ var CalendarCreate = common.Shortcut{
|
|||||||
"start": startStr,
|
"start": startStr,
|
||||||
"end": endStr,
|
"end": endStr,
|
||||||
}
|
}
|
||||||
|
if recurrence, _ := event["recurrence"].(string); recurrence != "" {
|
||||||
|
resultData["recurrence"] = recurrence
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
||||||
var rows []map[string]interface{}
|
var rows []map[string]interface{}
|
||||||
|
|||||||
279
shortcuts/calendar/calendar_get.go
Normal file
279
shortcuts/calendar/calendar_get.go
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// calendar +get — get a single calendar event detail by calendar_id and event_id
|
||||||
|
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// calendarEventTime mirrors start_time / end_time in the API response.
|
||||||
|
type calendarEventTime struct {
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Timestamp string `json:"timestamp,omitempty"`
|
||||||
|
Timezone string `json:"timezone,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventVChat mirrors the vchat block in the API response.
|
||||||
|
type calendarEventVChat struct {
|
||||||
|
VCType string `json:"vc_type,omitempty"`
|
||||||
|
IconType string `json:"icon_type,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
MeetingURL string `json:"meeting_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventLocation mirrors the location block in the API response.
|
||||||
|
type calendarEventLocation struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Latitude float64 `json:"latitude,omitempty"`
|
||||||
|
Longitude float64 `json:"longitude,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventReminder mirrors a reminder entry.
|
||||||
|
type calendarEventReminder struct {
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventOrganizer mirrors event_organizer.
|
||||||
|
type calendarEventOrganizer struct {
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventAttachment mirrors a single attachment entry.
|
||||||
|
type calendarEventAttachment struct {
|
||||||
|
FileToken string `json:"file_token,omitempty"`
|
||||||
|
FileSize string `json:"file_size,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventCheckInTime mirrors check_in_start_time / check_in_end_time.
|
||||||
|
type calendarEventCheckInTime struct {
|
||||||
|
TimeType string `json:"time_type,omitempty"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventCheckIn mirrors event_check_in.
|
||||||
|
type calendarEventCheckIn struct {
|
||||||
|
EnableCheckIn bool `json:"enable_check_in"`
|
||||||
|
CheckInStartTime *calendarEventCheckInTime `json:"check_in_start_time,omitempty"`
|
||||||
|
CheckInEndTime *calendarEventCheckInTime `json:"check_in_end_time,omitempty"`
|
||||||
|
NeedNotifyAttendees bool `json:"need_notify_attendees"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEvent mirrors the event object inside the API response.
|
||||||
|
type calendarEvent struct {
|
||||||
|
EventID string `json:"event_id,omitempty"`
|
||||||
|
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
||||||
|
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
||||||
|
VChat *calendarEventVChat `json:"vchat,omitempty"`
|
||||||
|
Visibility string `json:"visibility,omitempty"`
|
||||||
|
AttendeeAbility string `json:"attendee_ability,omitempty"`
|
||||||
|
FreeBusyStatus string `json:"free_busy_status,omitempty"`
|
||||||
|
SelfRsvpStatus string `json:"self_rsvp_status,omitempty"`
|
||||||
|
Location *calendarEventLocation `json:"location,omitempty"`
|
||||||
|
Color int `json:"color,omitempty"`
|
||||||
|
Reminders []calendarEventReminder `json:"reminders,omitempty"`
|
||||||
|
Recurrence string `json:"recurrence,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
IsException bool `json:"is_exception,omitempty"`
|
||||||
|
RecurringEventID string `json:"recurring_event_id,omitempty"`
|
||||||
|
CreateTime string `json:"create_time,omitempty"`
|
||||||
|
EventOrganizer *calendarEventOrganizer `json:"event_organizer,omitempty"`
|
||||||
|
AppLink string `json:"app_link,omitempty"`
|
||||||
|
Attachments []calendarEventAttachment `json:"attachments,omitempty"`
|
||||||
|
EventCheckIn *calendarEventCheckIn `json:"event_check_in,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCalendarEvent decodes the API response data into a typed calendarEvent.
|
||||||
|
func parseCalendarEvent(data map[string]any) (*calendarEvent, error) {
|
||||||
|
rawEvent, ok := data["event"]
|
||||||
|
if !ok || rawEvent == nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(rawEvent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: marshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
var event calendarEvent
|
||||||
|
if err := json.Unmarshal(raw, &event); err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: unmarshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
return &event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCalendarEventOutput converts the typed event into the output map and
|
||||||
|
// applies the four transformation rules:
|
||||||
|
// 1. create_time -> RFC3339
|
||||||
|
// 2. start_time / end_time timestamp -> datetime (RFC3339), drop timestamp
|
||||||
|
// 3. flatten event into the top-level result
|
||||||
|
// 4. when status != "cancelled", drop status (and adjust all-day end date)
|
||||||
|
func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, error) {
|
||||||
|
raw, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event marshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
var out map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event unmarshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctStr, ok := out["create_time"].(string); ok && ctStr != "" {
|
||||||
|
if ts, err := strconv.ParseInt(ctStr, 10, 64); err == nil {
|
||||||
|
out["create_time"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if startMap, ok := out["start_time"].(map[string]interface{}); ok {
|
||||||
|
if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" {
|
||||||
|
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||||
|
startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||||
|
delete(startMap, "timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if endMap, ok := out["end_time"].(map[string]interface{}); ok {
|
||||||
|
if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" {
|
||||||
|
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||||
|
endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||||
|
delete(endMap, "timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All-day event: end date is exclusive in the API; rewind by 1s and reformat.
|
||||||
|
if dt, _ := endMap["datetime"].(string); dt == "" {
|
||||||
|
if dateStr, ok := endMap["date"].(string); ok && dateStr != "" {
|
||||||
|
if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil {
|
||||||
|
endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, _ := out["status"].(string); status != "cancelled" {
|
||||||
|
delete(out, "status")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarGet gets a single calendar event detail.
|
||||||
|
var CalendarGet = common.Shortcut{
|
||||||
|
Service: "calendar",
|
||||||
|
Command: "+get",
|
||||||
|
Description: "Get a single calendar event detail by calendar-id and event-id",
|
||||||
|
Risk: "read",
|
||||||
|
Scopes: []string{"calendar:calendar.event:read"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
HasFormat: true,
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||||
|
{Name: "event-id", Desc: "event ID", Required: true},
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, flag := range []string{"calendar-id", "event-id"} {
|
||||||
|
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||||
|
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
|
if eventId == "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||||
|
d := common.NewDryRunAPI()
|
||||||
|
switch calendarId {
|
||||||
|
case "":
|
||||||
|
d.Desc("(calendar-id omitted) Will use primary calendar")
|
||||||
|
calendarId = "<primary>"
|
||||||
|
case "primary":
|
||||||
|
calendarId = "<primary>"
|
||||||
|
}
|
||||||
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
|
return d.
|
||||||
|
GET("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
|
||||||
|
Set("calendar_id", calendarId).
|
||||||
|
Set("event_id", eventId)
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||||
|
if calendarId == "" {
|
||||||
|
calendarId = PrimaryCalendarIDStr
|
||||||
|
}
|
||||||
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
|
|
||||||
|
data, err := runtime.CallAPITyped("GET",
|
||||||
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
|
||||||
|
validate.EncodePathSegment(calendarId),
|
||||||
|
validate.EncodePathSegment(eventId)),
|
||||||
|
nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
event, err := parseCalendarEvent(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := buildCalendarEventOutput(event)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||||
|
summary, _ := out["summary"].(string)
|
||||||
|
if summary == "" {
|
||||||
|
summary = "(untitled)"
|
||||||
|
}
|
||||||
|
startMap, _ := out["start_time"].(map[string]interface{})
|
||||||
|
endMap, _ := out["end_time"].(map[string]interface{})
|
||||||
|
startStr, _ := startMap["datetime"].(string)
|
||||||
|
if startStr == "" {
|
||||||
|
startStr, _ = startMap["date"].(string)
|
||||||
|
}
|
||||||
|
endStr, _ := endMap["datetime"].(string)
|
||||||
|
if endStr == "" {
|
||||||
|
endStr, _ = endMap["date"].(string)
|
||||||
|
}
|
||||||
|
eventIdOut, _ := out["event_id"].(string)
|
||||||
|
freeBusyStatus, _ := out["free_busy_status"].(string)
|
||||||
|
selfRsvpStatus, _ := out["self_rsvp_status"].(string)
|
||||||
|
row := map[string]interface{}{
|
||||||
|
"event_id": eventIdOut,
|
||||||
|
"summary": summary,
|
||||||
|
"start": startStr,
|
||||||
|
"end": endStr,
|
||||||
|
"free_busy_status": freeBusyStatus,
|
||||||
|
"self_rsvp_status": selfRsvpStatus,
|
||||||
|
}
|
||||||
|
output.PrintTable(w, []map[string]interface{}{row})
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -673,6 +673,76 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreate_ApprovalRoomMissingReason_GuidesRawAttendeesAPI(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_approval_room",
|
||||||
|
"summary": "Approval Room",
|
||||||
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||||
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/events/evt_approval_room/attendees",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": codeInvalidParamsWithDetail,
|
||||||
|
"msg": "invalid params",
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"details": []interface{}{
|
||||||
|
map[string]interface{}{"value": "attendees[0].approval_reason is required for approval meeting rooms"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "DELETE",
|
||||||
|
URL: "/events/evt_approval_room",
|
||||||
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarCreate, []string{
|
||||||
|
"+create",
|
||||||
|
"--summary", "Approval Room",
|
||||||
|
"--start", "2025-03-21T00:00:00+08:00",
|
||||||
|
"--end", "2025-03-21T01:00:00+08:00",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--attendee-ids", "omm_room1",
|
||||||
|
"--as", "user",
|
||||||
|
}, f, nil)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for approval room missing approval_reason, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf returned !ok for %T", err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryAPI {
|
||||||
|
t.Errorf("category=%q, want %q", p.Category, errs.CategoryAPI)
|
||||||
|
}
|
||||||
|
if p.Subtype != errs.SubtypeInvalidParameters {
|
||||||
|
t.Errorf("subtype=%q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
|
||||||
|
}
|
||||||
|
if p.Code != codeInvalidParamsWithDetail {
|
||||||
|
t.Errorf("code=%d, want %d", p.Code, codeInvalidParamsWithDetail)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"approval_reason", "calendar event.attendees create", "--as user", "rolled back successfully"} {
|
||||||
|
if !strings.Contains(p.Hint, want) {
|
||||||
|
t.Errorf("hint should contain %q, got: %q", want, p.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When the add-attendees call fails AND the rollback DELETE also fails, the
|
// When the add-attendees call fails AND the rollback DELETE also fails, the
|
||||||
// primary error stays the add failure (classification preserved) and the Hint
|
// 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
|
// must surface BOTH the rollback failure reason and the orphan event_id so the
|
||||||
@@ -2234,17 +2304,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
|||||||
// Shortcuts() registration test
|
// Shortcuts() registration test
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestShortcuts_Returns9(t *testing.T) {
|
func TestShortcuts_Returns10(t *testing.T) {
|
||||||
shortcuts := Shortcuts()
|
shortcuts := Shortcuts()
|
||||||
if len(shortcuts) != 9 {
|
if len(shortcuts) != 10 {
|
||||||
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
|
t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
|
||||||
}
|
}
|
||||||
|
|
||||||
names := map[string]bool{}
|
names := map[string]bool{}
|
||||||
for _, s := range shortcuts {
|
for _, s := range shortcuts {
|
||||||
names[s.Command] = true
|
names[s.Command] = true
|
||||||
}
|
}
|
||||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
|
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get"} {
|
||||||
if !names[want] {
|
if !names[want] {
|
||||||
t.Errorf("missing shortcut %s", want)
|
t.Errorf("missing shortcut %s", want)
|
||||||
}
|
}
|
||||||
@@ -3108,3 +3178,193 @@ func TestSuggestion_RejectsDangerousTimezone_Typed(t *testing.T) {
|
|||||||
t.Errorf("param=%q, want --timezone", ve.Param)
|
t.Errorf("param=%q, want --timezone", ve.Param)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CalendarGet tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_001",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_001",
|
||||||
|
"summary": "Daily Sync",
|
||||||
|
"create_time": "1602504000",
|
||||||
|
"start_time": map[string]interface{}{
|
||||||
|
"timestamp": "1742515200",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
"end_time": map[string]interface{}{
|
||||||
|
"timestamp": "1742518800",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
"status": "confirmed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_001",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
// Expect flattened — fields appear directly under "data", not under "data.event"
|
||||||
|
if strings.Contains(out, "\"event\": {") {
|
||||||
|
t.Errorf("payload should be flattened (no event wrapper), got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "\"event_id\": \"evt_001\"") {
|
||||||
|
t.Errorf("expected event_id in output, got: %s", out)
|
||||||
|
}
|
||||||
|
// status=confirmed should be dropped
|
||||||
|
if strings.Contains(out, "\"status\": \"confirmed\"") {
|
||||||
|
t.Errorf("status should be dropped when not cancelled, got: %s", out)
|
||||||
|
}
|
||||||
|
// timestamp must be replaced with datetime
|
||||||
|
if strings.Contains(out, "\"timestamp\":") {
|
||||||
|
t.Errorf("timestamp should be replaced with datetime, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "\"datetime\":") {
|
||||||
|
t.Errorf("expected datetime in output, got: %s", out)
|
||||||
|
}
|
||||||
|
// create_time must be RFC3339 (contain 'T' and timezone)
|
||||||
|
if !strings.Contains(out, "\"create_time\": \"2020-10-12T") {
|
||||||
|
t.Errorf("expected RFC3339 create_time, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_002",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_002",
|
||||||
|
"summary": "Cancelled Meeting",
|
||||||
|
"create_time": "1602504000",
|
||||||
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||||
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||||
|
"status": "cancelled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_002",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, "\"status\": \"cancelled\"") {
|
||||||
|
t.Errorf("status should be preserved when cancelled, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_AllDayEvent_AdjustsEndDate(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
// All-day event: start 2025-03-21, end 2025-03-22 (exclusive in API).
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_003",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_003",
|
||||||
|
"summary": "All-day",
|
||||||
|
"start_time": map[string]interface{}{"date": "2025-03-21"},
|
||||||
|
"end_time": map[string]interface{}{"date": "2025-03-22"},
|
||||||
|
"status": "confirmed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_003",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
// end date 2025-03-22 should rewind by 1s -> 2025-03-21
|
||||||
|
if !strings.Contains(out, "\"date\": \"2025-03-21\"") {
|
||||||
|
t.Errorf("expected end date adjusted to 2025-03-21, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_EmptyEventID_Typed(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--event-id", " ",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error for empty event-id")
|
||||||
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Param != "--event-id" {
|
||||||
|
t.Errorf("param=%q, want --event-id", ve.Param)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_MissingEventField_TypedInternal(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_404",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_404",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error when event field is missing")
|
||||||
|
}
|
||||||
|
var ie *errs.InternalError
|
||||||
|
if !errors.As(err, &ie) {
|
||||||
|
t.Fatalf("want *errs.InternalError, got %T", err)
|
||||||
|
}
|
||||||
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||||
|
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ func Shortcuts() []common.Shortcut {
|
|||||||
CalendarSuggestion,
|
CalendarSuggestion,
|
||||||
CalendarMeeting,
|
CalendarMeeting,
|
||||||
CalendarSearchEvent,
|
CalendarSearchEvent,
|
||||||
|
CalendarGet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1068,7 +1068,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
|||||||
if rctx.stdinConsumed {
|
if rctx.stdinConsumed {
|
||||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||||
WithParam("--"+fl.Name).
|
WithParam("--"+fl.Name).
|
||||||
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others inline or as @file with a relative path under the current directory (e.g. --%s @./payload.json)", fl.Name)
|
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
|
||||||
}
|
}
|
||||||
rctx.stdinConsumed = true
|
rctx.stdinConsumed = true
|
||||||
data, err := io.ReadAll(rctx.IO().In)
|
data, err := io.ReadAll(rctx.IO().In)
|
||||||
@@ -1102,16 +1102,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
|||||||
}
|
}
|
||||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
verr := ValidationErrorf("--%s: %v", fl.Name, err).
|
return ValidationErrorf("--%s: %v", fl.Name, err).
|
||||||
WithParam("--" + fl.Name).
|
WithParam("--" + fl.Name).
|
||||||
WithCause(err)
|
WithCause(err)
|
||||||
if slices.Contains(fl.Input, Stdin) {
|
|
||||||
// Rejected @file paths are usually absolute (temp files under
|
|
||||||
// /tmp). Steer toward stdin rather than cd / copying the file
|
|
||||||
// into the project tree.
|
|
||||||
verr = verr.WithHint("this flag also reads stdin: --%s - < %s", fl.Name, path)
|
|
||||||
}
|
|
||||||
return verr
|
|
||||||
}
|
}
|
||||||
// strip a leading UTF-8 BOM so it
|
// strip a leading UTF-8 BOM so it
|
||||||
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
||||||
|
|||||||
@@ -227,35 +227,6 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestResolveInputFlags_FileErrorSuggestsStdin pins the recovery hint when
|
|
||||||
// an @file path is rejected (typically an absolute /tmp path): flags that
|
|
||||||
// also accept stdin must point at `--flag - < path` — never at cd'ing into
|
|
||||||
// the target directory or copying the file into the project tree.
|
|
||||||
func TestResolveInputFlags_FileErrorSuggestsStdin(t *testing.T) {
|
|
||||||
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "@/tmp/does-not-exist.csv"}, "")
|
|
||||||
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
|
|
||||||
|
|
||||||
err := resolveInputFlags(rctx, flags)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for rejected @file path")
|
|
||||||
}
|
|
||||||
vErr := assertValidationParam(t, err, "--csv")
|
|
||||||
if !strings.Contains(vErr.Hint, "--csv - < /tmp/does-not-exist.csv") {
|
|
||||||
t.Errorf("hint %q should show the stdin form of the same call", vErr.Hint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A flag without stdin support must not get the stdin hint.
|
|
||||||
rctx = newTestRuntimeWithStdin(map[string]string{"file": "@/tmp/does-not-exist.xlsx"}, "")
|
|
||||||
err = resolveInputFlags(rctx, []Flag{{Name: "file", Input: []string{File}}})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for rejected @file path")
|
|
||||||
}
|
|
||||||
vErr = assertValidationParam(t, err, "--file")
|
|
||||||
if strings.Contains(vErr.Hint, "stdin") {
|
|
||||||
t.Errorf("hint %q must not suggest stdin for a file-only flag", vErr.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStripUTF8BOM(t *testing.T) {
|
func TestStripUTF8BOM(t *testing.T) {
|
||||||
cases := []struct{ name, in, want string }{
|
cases := []struct{ name, in, want string }{
|
||||||
{"leading BOM removed", "\uFEFFhello", "hello"},
|
{"leading BOM removed", "\uFEFFhello", "hello"},
|
||||||
|
|||||||
261
shortcuts/doc/docs_history.go
Normal file
261
shortcuts/doc/docs_history.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package doc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type docsHistoryListSpec struct {
|
||||||
|
Doc documentRef
|
||||||
|
PageSize int
|
||||||
|
PageToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
type docsHistoryRevertSpec struct {
|
||||||
|
Doc documentRef
|
||||||
|
HistoryVersionID string
|
||||||
|
WaitTimeoutMs int
|
||||||
|
}
|
||||||
|
|
||||||
|
type docsHistoryRevertStatusSpec struct {
|
||||||
|
Doc documentRef
|
||||||
|
TaskID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
|
||||||
|
ref, err := parseDocumentRef(raw)
|
||||||
|
if err != nil {
|
||||||
|
return documentRef{}, err
|
||||||
|
}
|
||||||
|
if ref.Kind == "doc" {
|
||||||
|
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
|
||||||
|
}
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDocsHistoryPageSize(pageSize int) error {
|
||||||
|
if pageSize < 1 || pageSize > 20 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDocsHistoryVersionID(historyVersionID string) error {
|
||||||
|
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
|
||||||
|
}
|
||||||
|
if version <= 0 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
|
||||||
|
if timeoutMs < 0 || timeoutMs > 30000 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"page_size": spec.PageSize,
|
||||||
|
}
|
||||||
|
if spec.PageToken != "" {
|
||||||
|
params["page_token"] = spec.PageToken
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"history_version_id": spec.HistoryVersionID,
|
||||||
|
"wait_timeout_ms": spec.WaitTimeoutMs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"task_id": spec.TaskID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsHistoryAPIPath(docToken, suffix string) string {
|
||||||
|
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
var DocsHistoryList = common.Shortcut{
|
||||||
|
Service: "docs",
|
||||||
|
Command: "+history-list",
|
||||||
|
Description: "List Lark document history versions",
|
||||||
|
Risk: "read",
|
||||||
|
Scopes: []string{"docx:document:readonly"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
PostMount: installDocsShortcutHelp("+history-list"),
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||||
|
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
|
||||||
|
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateDocsHistoryPageSize(runtime.Int("page-size"))
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||||
|
spec := docsHistoryListSpec{
|
||||||
|
Doc: ref,
|
||||||
|
PageSize: runtime.Int("page-size"),
|
||||||
|
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||||
|
}
|
||||||
|
return common.NewDryRunAPI().
|
||||||
|
Desc("OpenAPI: list document history versions").
|
||||||
|
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
|
||||||
|
Set("document_id", spec.Doc.Token).
|
||||||
|
Params(docsHistoryListParams(spec))
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||||
|
spec := docsHistoryListSpec{
|
||||||
|
Doc: ref,
|
||||||
|
PageSize: runtime.Int("page-size"),
|
||||||
|
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := runtime.CallAPITyped(
|
||||||
|
http.MethodGet,
|
||||||
|
docsHistoryAPIPath(spec.Doc.Token, "histories"),
|
||||||
|
docsHistoryListParams(spec),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
runtime.OutRaw(data, nil)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var DocsHistoryRevert = common.Shortcut{
|
||||||
|
Service: "docs",
|
||||||
|
Command: "+history-revert",
|
||||||
|
Description: "Revert a Lark document to a historical version",
|
||||||
|
Risk: "write",
|
||||||
|
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
PostMount: installDocsShortcutHelp("+history-revert"),
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||||
|
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
|
||||||
|
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||||
|
spec := docsHistoryRevertSpec{
|
||||||
|
Doc: ref,
|
||||||
|
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||||
|
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||||
|
}
|
||||||
|
return common.NewDryRunAPI().
|
||||||
|
Desc("OpenAPI: revert document history").
|
||||||
|
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
|
||||||
|
Set("document_id", spec.Doc.Token).
|
||||||
|
Body(docsHistoryRevertBody(spec))
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||||
|
spec := docsHistoryRevertSpec{
|
||||||
|
Doc: ref,
|
||||||
|
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||||
|
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := runtime.CallAPITyped(
|
||||||
|
http.MethodPost,
|
||||||
|
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
|
||||||
|
nil,
|
||||||
|
docsHistoryRevertBody(spec),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
runtime.OutRaw(data, nil)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var DocsHistoryRevertStatus = common.Shortcut{
|
||||||
|
Service: "docs",
|
||||||
|
Command: "+history-revert-status",
|
||||||
|
Description: "Get Lark document history revert task status",
|
||||||
|
Risk: "read",
|
||||||
|
Scopes: []string{"docx:document:readonly"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
PostMount: installDocsShortcutHelp("+history-revert-status"),
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||||
|
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(runtime.Str("task-id")) == "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||||
|
spec := docsHistoryRevertStatusSpec{
|
||||||
|
Doc: ref,
|
||||||
|
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||||
|
}
|
||||||
|
return common.NewDryRunAPI().
|
||||||
|
Desc("OpenAPI: get document history revert status").
|
||||||
|
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
|
||||||
|
Set("document_id", spec.Doc.Token).
|
||||||
|
Params(docsHistoryStatusParams(spec))
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||||
|
spec := docsHistoryRevertStatusSpec{
|
||||||
|
Doc: ref,
|
||||||
|
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := runtime.CallAPITyped(
|
||||||
|
http.MethodGet,
|
||||||
|
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
|
||||||
|
docsHistoryStatusParams(spec),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
runtime.OutRaw(data, nil)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
340
shortcuts/doc/docs_history_test.go
Normal file
340
shortcuts/doc/docs_history_test.go
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
// 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,12 +27,17 @@ const (
|
|||||||
html5BlockDataAttr = "data"
|
html5BlockDataAttr = "data"
|
||||||
html5BlockReferenceRoot = "doc-fetch-resources"
|
html5BlockReferenceRoot = "doc-fetch-resources"
|
||||||
html5BlockReferenceMaxRaw = 1024
|
html5BlockReferenceMaxRaw = 1024
|
||||||
|
|
||||||
|
whiteboardTag = "whiteboard"
|
||||||
|
whiteboardTypeAttr = "type"
|
||||||
|
whiteboardPathAttr = "path"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
|
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
|
||||||
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
|
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
|
||||||
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||||
|
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*(?:/>|>.*?</whiteboard>)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
type html5BlockReferenceEntry struct {
|
type html5BlockReferenceEntry struct {
|
||||||
@@ -58,6 +63,11 @@ type html5BlockStartTag struct {
|
|||||||
SelfClosing bool
|
SelfClosing bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type whiteboardStartTag struct {
|
||||||
|
Attrs []html5BlockAttr
|
||||||
|
SelfClosing bool
|
||||||
|
}
|
||||||
|
|
||||||
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||||
body := buildCreateBody(runtime)
|
body := buildCreateBody(runtime)
|
||||||
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
|
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
|
||||||
@@ -115,7 +125,11 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
|
|||||||
return docsV2WriteInput{}, err
|
return docsV2WriteInput{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return docsV2WriteInput{}, err
|
return docsV2WriteInput{}, err
|
||||||
}
|
}
|
||||||
@@ -232,6 +246,248 @@ func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string
|
|||||||
return out, compactReferenceMap(refMap), nil
|
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 {
|
func validateHTML5BlockWriteElementBodies(format string, content string) error {
|
||||||
validateSegment := func(segment string) error {
|
validateSegment := func(segment string) error {
|
||||||
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
|
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
|
||||||
@@ -621,6 +877,34 @@ func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
|
|||||||
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
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) {
|
func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||||
for _, attr := range t.Attrs {
|
for _, attr := range t.Attrs {
|
||||||
if attr.Name == name {
|
if attr.Name == name {
|
||||||
@@ -630,6 +914,15 @@ func (t html5BlockStartTag) attr(name string) (string, bool) {
|
|||||||
return "", false
|
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 {
|
func (t html5BlockStartTag) hasAttr(name string) bool {
|
||||||
_, ok := t.attr(name)
|
_, ok := t.attr(name)
|
||||||
return ok
|
return ok
|
||||||
@@ -650,6 +943,31 @@ func (t *html5BlockStartTag) removeAttrs(names ...string) {
|
|||||||
t.Attrs = attrs
|
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 {
|
func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteByte('<')
|
b.WriteByte('<')
|
||||||
@@ -674,6 +992,25 @@ func (t html5BlockStartTag) render(selfClosing bool) string {
|
|||||||
return b.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 {
|
func escapeXMLAttr(value string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, r := range value {
|
for _, r := range value {
|
||||||
@@ -694,3 +1031,18 @@ func escapeXMLAttr(value string) string {
|
|||||||
}
|
}
|
||||||
return b.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,11 +6,13 @@ package doc
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -116,6 +118,61 @@ 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 {
|
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
|
||||||
for _, flag := range flags {
|
for _, flag := range flags {
|
||||||
if flag.Name == name {
|
if flag.Name == name {
|
||||||
@@ -407,6 +464,119 @@ 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) {
|
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
cmdutil.TestChdir(t, dir)
|
cmdutil.TestChdir(t, dir)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
|
|||||||
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
||||||
case "update":
|
case "update":
|
||||||
return docsSkillReadCommand + " references/lark-doc-update.md"
|
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||||
|
case "history-list", "history-revert", "history-revert-status":
|
||||||
|
return docsSkillReadCommand + " references/lark-doc-history.md"
|
||||||
default:
|
default:
|
||||||
return docsSkillReadCommand
|
return docsSkillReadCommand
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string {
|
|||||||
return "lark-cli docs +fetch --help"
|
return "lark-cli docs +fetch --help"
|
||||||
case "update":
|
case "update":
|
||||||
return "lark-cli docs +update --help"
|
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:
|
default:
|
||||||
return "lark-cli docs --help"
|
return "lark-cli docs --help"
|
||||||
}
|
}
|
||||||
@@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut {
|
|||||||
DocsCreate,
|
DocsCreate,
|
||||||
DocsFetch,
|
DocsFetch,
|
||||||
DocsUpdate,
|
DocsUpdate,
|
||||||
|
DocsHistoryList,
|
||||||
|
DocsHistoryRevert,
|
||||||
|
DocsHistoryRevertStatus,
|
||||||
DocMediaInsert,
|
DocMediaInsert,
|
||||||
DocMediaUpload,
|
DocMediaUpload,
|
||||||
DocMediaPreview,
|
DocMediaPreview,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ const (
|
|||||||
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
|
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
|
||||||
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
||||||
driveImport800MBFileSizeLimit int64 = 800 * 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
|
// driveImportExtToDocTypes defines which source file extensions can be imported
|
||||||
@@ -47,6 +50,8 @@ var driveImportExtToDocTypes = map[string][]string{
|
|||||||
"pptx": {"slides"},
|
"pptx": {"slides"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
|
||||||
|
|
||||||
// driveImportSpec contains the user-facing import inputs after normalization.
|
// driveImportSpec contains the user-facing import inputs after normalization.
|
||||||
type driveImportSpec struct {
|
type driveImportSpec struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -427,11 +432,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
|||||||
return status, true, nil
|
return status, true, nil
|
||||||
}
|
}
|
||||||
if status.Failed() {
|
if status.Failed() {
|
||||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
return status, false, driveImportFailureError(status)
|
||||||
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 {
|
if !hadSuccessfulPoll && lastErr != nil {
|
||||||
@@ -440,3 +441,40 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
|||||||
|
|
||||||
return lastStatus, false, nil
|
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,6 +7,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -211,6 +212,82 @@ 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) {
|
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ var DrivePull = common.Shortcut{
|
|||||||
|
|
||||||
var downloaded, skipped, failed, deletedLocal int
|
var downloaded, skipped, failed, deletedLocal int
|
||||||
downloadFailed := 0
|
downloadFailed := 0
|
||||||
|
aborted := false
|
||||||
items := make([]drivePullItem, 0)
|
items := make([]drivePullItem, 0)
|
||||||
|
|
||||||
// Deterministic iteration order for output stability.
|
// Deterministic iteration order for output stability.
|
||||||
@@ -194,7 +195,7 @@ var DrivePull = common.Shortcut{
|
|||||||
sort.Strings(downloadablePaths)
|
sort.Strings(downloadablePaths)
|
||||||
|
|
||||||
for _, rel := range downloadablePaths {
|
for _, rel := range downloadablePaths {
|
||||||
if drivePullHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
targetFile := remoteFiles[rel]
|
targetFile := remoteFiles[rel]
|
||||||
@@ -232,6 +233,7 @@ var DrivePull = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
downloadFailed++
|
downloadFailed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -298,7 +300,7 @@ var DrivePull = common.Shortcut{
|
|||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
"deleted_local": deletedLocal,
|
"deleted_local": deletedLocal,
|
||||||
"aborted": drivePullHasTerminalFailure(items),
|
"aborted": aborted,
|
||||||
},
|
},
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
@@ -347,15 +349,6 @@ func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err
|
|||||||
return item, decision.Terminal
|
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
|
// drivePullDownload streams one Drive file into the local mirror target and
|
||||||
// then best-effort aligns the local mtime to Drive's modified_time.
|
// 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 {
|
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type drivePushItem struct {
|
|||||||
Version string `json:"version,omitempty"`
|
Version string `json:"version,omitempty"`
|
||||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
Phase string `json:"phase,omitempty"`
|
Phase string `json:"phase,omitempty"`
|
||||||
ErrorClass string `json:"error_class,omitempty"`
|
ErrorClass string `json:"error_class,omitempty"`
|
||||||
Code int `json:"code,omitempty"`
|
Code int `json:"code,omitempty"`
|
||||||
@@ -48,6 +49,7 @@ type driveBatchFailureDecision struct {
|
|||||||
Subtype string
|
Subtype string
|
||||||
Retryable bool
|
Retryable bool
|
||||||
Terminal bool
|
Terminal bool
|
||||||
|
Hint string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
||||||
@@ -240,6 +242,7 @@ var DrivePush = common.Shortcut{
|
|||||||
// locally and now on Drive too), which is the worst-of-both-worlds
|
// locally and now on Drive too), which is the worst-of-both-worlds
|
||||||
// outcome the review flagged.
|
// outcome the review flagged.
|
||||||
uploadFailed := false
|
uploadFailed := false
|
||||||
|
aborted := false
|
||||||
|
|
||||||
// folderCache holds rel_path → folder_token. Seeded from the remote
|
// folderCache holds rel_path → folder_token. Seeded from the remote
|
||||||
// listing (so we don't recreate folders that already exist) and
|
// listing (so we don't recreate folders that already exist) and
|
||||||
@@ -266,6 +269,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -284,7 +288,7 @@ var DrivePush = common.Shortcut{
|
|||||||
|
|
||||||
for _, rel := range localPaths {
|
for _, rel := range localPaths {
|
||||||
localFile := localFiles[rel]
|
localFile := localFiles[rel]
|
||||||
if uploadFailed && drivePushHasTerminalFailure(items) {
|
if uploadFailed && aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +305,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -332,6 +337,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -350,6 +356,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -362,6 +369,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -407,10 +415,15 @@ var DrivePush = common.Shortcut{
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
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)
|
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
||||||
abortDelete = true
|
abortDelete = true
|
||||||
break
|
break
|
||||||
@@ -429,7 +442,7 @@ var DrivePush = common.Shortcut{
|
|||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
"deleted_remote": deletedRemote,
|
"deleted_remote": deletedRemote,
|
||||||
"aborted": drivePushHasTerminalFailure(items),
|
"aborted": aborted,
|
||||||
},
|
},
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
@@ -567,6 +580,7 @@ func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int
|
|||||||
Action: action,
|
Action: action,
|
||||||
SizeBytes: sizeBytes,
|
SizeBytes: sizeBytes,
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
|
Hint: decision.Hint,
|
||||||
Phase: phase,
|
Phase: phase,
|
||||||
ErrorClass: decision.Class,
|
ErrorClass: decision.Class,
|
||||||
Code: decision.Code,
|
Code: decision.Code,
|
||||||
@@ -613,6 +627,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
|||||||
decision.Class = "file_size_limit"
|
decision.Class = "file_size_limit"
|
||||||
case problem.Code == 1062009:
|
case problem.Code == 1062009:
|
||||||
decision.Class = "upload_size_mismatch"
|
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:
|
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
|
||||||
decision.Class = "remote_not_found"
|
decision.Class = "remote_not_found"
|
||||||
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
||||||
@@ -626,22 +644,9 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
|||||||
return decision
|
return decision
|
||||||
}
|
}
|
||||||
|
|
||||||
func drivePushHasTerminalFailure(items []drivePushItem) bool {
|
func drivePushIsAlreadyDeleted(err error) bool {
|
||||||
for _, item := range items {
|
problem, ok := errs.ProblemOf(err)
|
||||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
return ok && problem.Code == 1061007
|
||||||
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) {
|
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||||
|
|||||||
@@ -732,6 +732,65 @@ 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) {
|
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
|
|
||||||
@@ -1137,6 +1196,78 @@ 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) {
|
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
|
|||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
|
{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: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
{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"},
|
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ var DriveSync = common.Shortcut{
|
|||||||
|
|
||||||
// --- Phase 2: Execute sync operations ---
|
// --- Phase 2: Execute sync operations ---
|
||||||
var pulled, pushed, skipped, failed int
|
var pulled, pushed, skipped, failed int
|
||||||
|
aborted := false
|
||||||
items := make([]driveSyncItem, 0)
|
items := make([]driveSyncItem, 0)
|
||||||
|
|
||||||
// Build push infrastructure: local walk for push + remote views + folder cache.
|
// Build push infrastructure: local walk for push + remote views + folder cache.
|
||||||
@@ -286,16 +287,21 @@ var DriveSync = common.Shortcut{
|
|||||||
// Mirror local directory structure first (same as +push), so
|
// Mirror local directory structure first (same as +push), so
|
||||||
// empty local directories are not silently dropped.
|
// empty local directories are not silently dropped.
|
||||||
for _, relDir := range localDirs {
|
for _, relDir := range localDirs {
|
||||||
if driveSyncHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||||
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
item, terminal := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
|
if terminal {
|
||||||
|
aborted = true
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||||
|
break
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
|
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
|
||||||
@@ -304,7 +310,7 @@ var DriveSync = common.Shortcut{
|
|||||||
|
|
||||||
// 2a. Pull new_remote files.
|
// 2a. Pull new_remote files.
|
||||||
for _, entry := range newRemote {
|
for _, entry := range newRemote {
|
||||||
if driveSyncHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||||
@@ -318,6 +324,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -329,7 +336,7 @@ var DriveSync = common.Shortcut{
|
|||||||
|
|
||||||
// 2b. Push new_local files.
|
// 2b. Push new_local files.
|
||||||
for _, entry := range newLocal {
|
for _, entry := range newLocal {
|
||||||
if driveSyncHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
localFile, ok := pushLocalFiles[entry.RelPath]
|
localFile, ok := pushLocalFiles[entry.RelPath]
|
||||||
@@ -341,9 +348,14 @@ var DriveSync = common.Shortcut{
|
|||||||
parentRel := drivePushParentRel(entry.RelPath)
|
parentRel := drivePushParentRel(entry.RelPath)
|
||||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||||
if ensureErr != nil {
|
if ensureErr != nil {
|
||||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
item, terminal := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
|
if terminal {
|
||||||
|
aborted = true
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||||
|
break
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||||
@@ -352,6 +364,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -363,7 +376,7 @@ var DriveSync = common.Shortcut{
|
|||||||
|
|
||||||
// 2c. Resolve modified files by --on-conflict strategy.
|
// 2c. Resolve modified files by --on-conflict strategy.
|
||||||
for _, entry := range modified {
|
for _, entry := range modified {
|
||||||
if driveSyncHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
remoteFile := remoteFiles[entry.RelPath]
|
remoteFile := remoteFiles[entry.RelPath]
|
||||||
@@ -397,6 +410,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -415,9 +429,14 @@ var DriveSync = common.Shortcut{
|
|||||||
}
|
}
|
||||||
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
||||||
if parentErr != nil {
|
if parentErr != nil {
|
||||||
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
item, terminal := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
|
if terminal {
|
||||||
|
aborted = true
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||||
|
break
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
|
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
|
||||||
@@ -435,6 +454,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -503,6 +523,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -531,7 +552,7 @@ var DriveSync = common.Shortcut{
|
|||||||
"pushed": pushed,
|
"pushed": pushed,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
"aborted": driveSyncHasTerminalFailure(items),
|
"aborted": aborted,
|
||||||
},
|
},
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
@@ -577,15 +598,6 @@ func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, er
|
|||||||
return item, decision.Terminal
|
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
|
// driveSyncAskConflict prompts the user for a conflict resolution strategy
|
||||||
// for a single file. Returns the strategy string, or empty string if the
|
// for a single file. Returns the strategy string, or empty string if the
|
||||||
// user chose to skip.
|
// user chose to skip.
|
||||||
|
|||||||
@@ -51,9 +51,8 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
|
|||||||
// original message as read after a reply/reply-all/forward operation.
|
// original message as read after a reply/reply-all/forward operation.
|
||||||
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
|
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
|
||||||
fmt.Fprintf(runtime.IO().ErrOut,
|
fmt.Fprintf(runtime.IO().ErrOut,
|
||||||
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
|
"tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
|
||||||
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
|
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
|
||||||
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// hintReadReceiptRequest prints a stderr tip when a message that the caller
|
// hintReadReceiptRequest prints a stderr tip when a message that the caller
|
||||||
|
|||||||
@@ -465,14 +465,19 @@ func TestPrintWatchOutputSchema(t *testing.T) {
|
|||||||
// TestHintMarkAsRead verifies hint mark as read.
|
// TestHintMarkAsRead verifies hint mark as read.
|
||||||
func TestHintMarkAsRead(t *testing.T) {
|
func TestHintMarkAsRead(t *testing.T) {
|
||||||
rt, _, stderr := newOutputRuntime(t)
|
rt, _, stderr := newOutputRuntime(t)
|
||||||
// Inject ANSI escape + message ID to verify sanitization
|
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
|
||||||
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
|
|
||||||
out := stderr.String()
|
out := stderr.String()
|
||||||
if strings.Contains(out, "\x1b[") {
|
if strings.Contains(out, "\x1b[") {
|
||||||
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
|
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "msg-123") {
|
if strings.Contains(out, "\nnext") {
|
||||||
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
482
shortcuts/mail/mail_message_manage_test.go
Normal file
482
shortcuts/mail/mail_message_manage_test.go
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
141
shortcuts/mail/mail_message_modify.go
Normal file
141
shortcuts/mail/mail_message_modify.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
75
shortcuts/mail/mail_message_trash.go
Normal file
75
shortcuts/mail/mail_message_trash.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// 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",
|
RefreshToken: "test-refresh-token",
|
||||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||||
RefreshExpiresAt: time.Now().Add(24 * 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",
|
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",
|
||||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||||
}
|
}
|
||||||
if err := auth.SetStoredToken(token); err != nil {
|
if err := auth.SetStoredToken(token); err != nil {
|
||||||
|
|||||||
283
shortcuts/mail/message_manage_helpers.go
Normal file
283
shortcuts/mail/message_manage_helpers.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
// 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,6 +10,8 @@ func Shortcuts() []common.Shortcut {
|
|||||||
return []common.Shortcut{
|
return []common.Shortcut{
|
||||||
MailMessage,
|
MailMessage,
|
||||||
MailMessages,
|
MailMessages,
|
||||||
|
MailMessageModify,
|
||||||
|
MailMessageTrash,
|
||||||
MailThread,
|
MailThread,
|
||||||
MailTriage,
|
MailTriage,
|
||||||
MailWatch,
|
MailWatch,
|
||||||
|
|||||||
@@ -715,9 +715,15 @@ func markdownUploadProblem(err error, action string) error {
|
|||||||
case 90003087:
|
case 90003087:
|
||||||
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
|
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
|
||||||
case 1061003, 1061044:
|
case 1061003, 1061044:
|
||||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
|
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the parent token type. For Drive folders, pass --folder-token with a Drive folder token/URL; for wiki nodes, pass --wiki-token with a wiki node token/URL.")
|
||||||
case 1061004, 1062501:
|
case 1061004, 1062501:
|
||||||
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
|
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
|
||||||
|
case 1061101:
|
||||||
|
appendMarkdownProblemHint(err, "The target Drive/wiki storage quota is exhausted. Free space, choose another parent folder/wiki node, or ask an administrator to raise quota before retrying.")
|
||||||
|
case 233523001:
|
||||||
|
appendMarkdownProblemHint(err, "The upstream document service returned a transient server error. Retry later; if it repeats, keep the log_id/request_id for service-side investigation.")
|
||||||
|
case 99991400:
|
||||||
|
appendMarkdownProblemHint(err, "The upload API is rate limited. Stop immediate retries and retry later with exponential backoff.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,27 +31,19 @@ var MarkdownCreate = common.Shortcut{
|
|||||||
Tips: []string{
|
Tips: []string{
|
||||||
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
|
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
|
||||||
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
||||||
|
"--folder-token and --wiki-token also accept full Lark URLs and normalize them to the required token.",
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
spec, err := readMarkdownCreateSpec(runtime)
|
||||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
if err != nil {
|
||||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
return err
|
||||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
}
|
||||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
return validateMarkdownSpec(runtime, spec, true)
|
||||||
FileSet: runtime.Changed("file"),
|
|
||||||
Content: runtime.Str("content"),
|
|
||||||
ContentSet: runtime.Changed("content"),
|
|
||||||
}, true)
|
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
spec := markdownUploadSpec{
|
spec, err := readMarkdownCreateSpec(runtime)
|
||||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
if err != nil {
|
||||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
return common.NewDryRunAPI().Set("error", err.Error())
|
||||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
|
||||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
|
||||||
FileSet: runtime.Changed("file"),
|
|
||||||
Content: runtime.Str("content"),
|
|
||||||
ContentSet: runtime.Changed("content"),
|
|
||||||
}
|
}
|
||||||
fileSize, err := markdownSourceSize(runtime, spec)
|
fileSize, err := markdownSourceSize(runtime, spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,14 +64,9 @@ var MarkdownCreate = common.Shortcut{
|
|||||||
return dry
|
return dry
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
spec := markdownUploadSpec{
|
spec, err := readMarkdownCreateSpec(runtime)
|
||||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
if err != nil {
|
||||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
return err
|
||||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
|
||||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
|
||||||
FileSet: runtime.Changed("file"),
|
|
||||||
Content: runtime.Str("content"),
|
|
||||||
ContentSet: runtime.Changed("content"),
|
|
||||||
}
|
}
|
||||||
fileSize, err := markdownSourceSize(runtime, spec)
|
fileSize, err := markdownSourceSize(runtime, spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,3 +103,139 @@ var MarkdownCreate = common.Shortcut{
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readMarkdownCreateSpec(runtime *common.RuntimeContext) (markdownUploadSpec, error) {
|
||||||
|
spec := markdownUploadSpec{
|
||||||
|
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||||
|
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||||
|
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||||
|
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||||
|
FileSet: runtime.Changed("file"),
|
||||||
|
Content: runtime.Str("content"),
|
||||||
|
ContentSet: runtime.Changed("content"),
|
||||||
|
}
|
||||||
|
return normalizeMarkdownCreateTargetSpec(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMarkdownCreateTargetSpec(spec markdownUploadSpec) (markdownUploadSpec, error) {
|
||||||
|
if spec.FolderToken != "" {
|
||||||
|
token, err := normalizeMarkdownFolderToken(spec.FolderToken)
|
||||||
|
if err != nil {
|
||||||
|
return markdownUploadSpec{}, err
|
||||||
|
}
|
||||||
|
spec.FolderToken = token
|
||||||
|
}
|
||||||
|
if spec.WikiToken != "" {
|
||||||
|
token, err := normalizeMarkdownWikiToken(spec.WikiToken)
|
||||||
|
if err != nil {
|
||||||
|
return markdownUploadSpec{}, err
|
||||||
|
}
|
||||||
|
spec.WikiToken = token
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMarkdownFolderToken(token string) (string, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if strings.Contains(token, "://") {
|
||||||
|
ref, ok := common.ParseResourceURL(token)
|
||||||
|
if !ok {
|
||||||
|
return "", markdownValidationParamError("--folder-token", "--folder-token URL is unsupported").
|
||||||
|
WithHint("Pass a Drive folder URL or raw folder token.")
|
||||||
|
}
|
||||||
|
if ref.Type != "folder" {
|
||||||
|
return "", markdownValidationParamError("--folder-token",
|
||||||
|
"--folder-token must identify a Drive folder; got a %s URL",
|
||||||
|
ref.Type,
|
||||||
|
).WithHint("Use --wiki-token for wiki nodes or pass a Drive folder URL/token.")
|
||||||
|
}
|
||||||
|
if err := validateMarkdownTargetTokenName(ref.Token, "--folder-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return ref.Token, nil
|
||||||
|
}
|
||||||
|
if err := rejectMarkdownPartialToken(token, "--folder-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch markdownKnownResourceTokenKind(token) {
|
||||||
|
case "wiki":
|
||||||
|
return "", markdownValidationParamError("--folder-token", "--folder-token looks like a wiki node token").
|
||||||
|
WithHint("Pass it with --wiki-token instead.")
|
||||||
|
case "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file":
|
||||||
|
return "", markdownValidationParamError("--folder-token", "--folder-token must be a Drive folder token, not a %s token", markdownKnownResourceTokenKind(token))
|
||||||
|
}
|
||||||
|
if err := validateMarkdownTargetTokenName(token, "--folder-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMarkdownWikiToken(token string) (string, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if strings.Contains(token, "://") {
|
||||||
|
ref, ok := common.ParseResourceURL(token)
|
||||||
|
if !ok {
|
||||||
|
return "", markdownValidationParamError("--wiki-token", "--wiki-token URL is unsupported").
|
||||||
|
WithHint("Pass a wiki node URL or raw wiki node token.")
|
||||||
|
}
|
||||||
|
if ref.Type != "wiki" {
|
||||||
|
return "", markdownValidationParamError("--wiki-token",
|
||||||
|
"--wiki-token must identify a wiki node; got a %s URL",
|
||||||
|
ref.Type,
|
||||||
|
).WithHint("Resolve document URLs with `lark-cli wiki +node-get --node-token <url>` and use the returned node_token.")
|
||||||
|
}
|
||||||
|
if err := validateMarkdownTargetTokenName(ref.Token, "--wiki-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return ref.Token, nil
|
||||||
|
}
|
||||||
|
if err := rejectMarkdownPartialToken(token, "--wiki-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if kind := markdownKnownResourceTokenKind(token); kind != "" && kind != "wiki" {
|
||||||
|
return "", markdownValidationParamError("--wiki-token", "--wiki-token must be a wiki node token, not a %s token", kind)
|
||||||
|
}
|
||||||
|
if err := validateMarkdownTargetTokenName(token, "--wiki-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectMarkdownPartialToken(token, flagName string) error {
|
||||||
|
if strings.ContainsAny(token, "/?#") {
|
||||||
|
return markdownValidationParamError(flagName, "%s must be a raw token, not a path, query, or fragment", flagName).
|
||||||
|
WithHint("Pass a full Lark URL, or copy only the token value without path/query/fragment characters.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMarkdownTargetTokenName(token, flagName string) error {
|
||||||
|
if err := validate.ResourceName(token, flagName); err != nil {
|
||||||
|
return markdownValidationParamError(flagName, "%s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func markdownKnownResourceTokenKind(token string) string {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(token))
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lower, "wik"):
|
||||||
|
return "wiki"
|
||||||
|
case strings.HasPrefix(lower, "docx"):
|
||||||
|
return "docx"
|
||||||
|
case strings.HasPrefix(lower, "doc"):
|
||||||
|
return "doc"
|
||||||
|
case strings.HasPrefix(lower, "sht"):
|
||||||
|
return "sheet"
|
||||||
|
case strings.HasPrefix(lower, "bas"):
|
||||||
|
return "bitable"
|
||||||
|
case strings.HasPrefix(lower, "mn"):
|
||||||
|
return "mindnote"
|
||||||
|
case strings.HasPrefix(lower, "sld"):
|
||||||
|
return "slides"
|
||||||
|
case strings.HasPrefix(lower, "box"), strings.HasPrefix(lower, "file"):
|
||||||
|
return "file"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -446,6 +446,173 @@ func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarkdownCreateDryRunNormalizesFolderURL(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||||
|
|
||||||
|
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||||
|
"+create",
|
||||||
|
"--name", "README.md",
|
||||||
|
"--content", "# hello",
|
||||||
|
"--folder-token", "https://feishu.cn/drive/folder/fldcnMarkdownTarget",
|
||||||
|
"--dry-run",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, `"parent_type": "explorer"`) {
|
||||||
|
t.Fatalf("dry-run missing explorer parent_type: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"parent_node": "fldcnMarkdownTarget"`) {
|
||||||
|
t.Fatalf("dry-run did not normalize folder URL to token: %s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "https://feishu.cn/drive/folder/") {
|
||||||
|
t.Fatalf("dry-run leaked raw folder URL instead of token: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownCreateRejectsWikiURLInFolderToken(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||||
|
|
||||||
|
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||||
|
"+create",
|
||||||
|
"--name", "README.md",
|
||||||
|
"--content", "# hello",
|
||||||
|
"--folder-token", "https://feishu.cn/wiki/wikcnWrongFlag",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected folder-token URL type error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Message, "must identify a Drive folder") || !strings.Contains(p.Hint, "Use --wiki-token") {
|
||||||
|
t.Fatalf("expected folder-token URL type error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownCreateRejectsDocURLInWikiToken(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||||
|
|
||||||
|
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||||
|
"+create",
|
||||||
|
"--name", "README.md",
|
||||||
|
"--content", "# hello",
|
||||||
|
"--wiki-token", "https://feishu.cn/docx/docxWrongFlag",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected wiki-token URL type error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
|
||||||
|
t.Fatalf("expected wiki-token URL type error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMarkdownTargetTokensRejectAmbiguousInputs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
run func() (string, error)
|
||||||
|
wantMsg string
|
||||||
|
wantHint string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "wiki token passed as folder token",
|
||||||
|
run: func() (string, error) { return normalizeMarkdownFolderToken("wik_placeholder_wrong") },
|
||||||
|
wantMsg: "--folder-token looks like a wiki node token",
|
||||||
|
wantHint: "--wiki-token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "folder token path fragment",
|
||||||
|
run: func() (string, error) { return normalizeMarkdownFolderToken("folder_token/child") },
|
||||||
|
wantMsg: "--folder-token must be a raw token",
|
||||||
|
wantHint: "full Lark URL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "doc token passed as wiki token",
|
||||||
|
run: func() (string, error) { return normalizeMarkdownWikiToken("docx_placeholder_wrong") },
|
||||||
|
wantMsg: "--wiki-token must be a wiki node token",
|
||||||
|
wantHint: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wiki token query fragment",
|
||||||
|
run: func() (string, error) { return normalizeMarkdownWikiToken("wik_placeholder?from=copy") },
|
||||||
|
wantMsg: "--wiki-token must be a raw token",
|
||||||
|
wantHint: "path/query/fragment",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := tt.run()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected validation error")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Message, tt.wantMsg) {
|
||||||
|
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
|
||||||
|
}
|
||||||
|
if tt.wantHint != "" && !strings.Contains(p.Hint, tt.wantHint) {
|
||||||
|
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.wantHint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMarkdownTargetTokensAcceptRawTokens(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
folderToken, err := normalizeMarkdownFolderToken("folder_token_raw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeMarkdownFolderToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if folderToken != "folder_token_raw" {
|
||||||
|
t.Fatalf("folder token = %q", folderToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiToken, err := normalizeMarkdownWikiToken("wik_placeholder_raw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeMarkdownWikiToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if wikiToken != "wik_placeholder_raw" {
|
||||||
|
t.Fatalf("wiki token = %q", wikiToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownUploadProblemAddsQuotaAndServerHints(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
quotaErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "file quota exceeded").WithCode(1061101)
|
||||||
|
got := markdownUploadProblem(quotaErr, markdownUploadAllAction)
|
||||||
|
p, ok := errs.ProblemOf(got)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf(quotaErr) ok=false")
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Hint, "storage quota is exhausted") {
|
||||||
|
t.Fatalf("quota hint = %q", p.Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverErr := errs.NewAPIError(errs.SubtypeServerError, "NA").WithCode(233523001).WithRetryable()
|
||||||
|
got = markdownUploadProblem(serverErr, markdownUploadAllAction)
|
||||||
|
p, ok = errs.ProblemOf(got)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf(serverErr) ok=false")
|
||||||
|
}
|
||||||
|
if !p.Retryable || !strings.Contains(p.Hint, "transient server error") {
|
||||||
|
t.Fatalf("server retryable=%v hint=%q", p.Retryable, p.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ import (
|
|||||||
const minutesDetailLogPrefix = "[minutes +detail]"
|
const minutesDetailLogPrefix = "[minutes +detail]"
|
||||||
|
|
||||||
// Error codes from the minutes API.
|
// Error codes from the minutes API.
|
||||||
const minutesDetailNoReadPermissionCode = 2091005
|
const (
|
||||||
|
minutesDetailProcessingCode = 2091003
|
||||||
|
minutesDetailNoReadPermissionCode = 2091005
|
||||||
|
minutesDetailWaitTimeoutDefault = 300
|
||||||
|
minutesDetailWaitIntervalDefault = 15
|
||||||
|
)
|
||||||
|
|
||||||
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||||
|
|
||||||
@@ -40,19 +45,31 @@ var scopesDetailMinuteTokens = []string{
|
|||||||
// minuteDetailItem represents a single minute detail result.
|
// minuteDetailItem represents a single minute detail result.
|
||||||
type minuteDetailItem struct {
|
type minuteDetailItem struct {
|
||||||
MinuteToken string `json:"minute_token"`
|
MinuteToken string `json:"minute_token"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
NoteID string `json:"note_id"`
|
NoteID string `json:"note_id"`
|
||||||
Artifacts map[string]any `json:"artifacts,omitempty"`
|
Artifacts map[string]any `json:"artifacts,omitempty"`
|
||||||
|
Retryable bool `json:"retryable,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
NextCommand string `json:"next_command,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
|
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
|
||||||
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
||||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
artifactFlags := requestedMinutesDetailArtifactFlags(runtime)
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
waitReady := runtime.Bool("wait-ready")
|
||||||
|
waitTimeout, waitInterval := minutesDetailWaitConfig(runtime)
|
||||||
|
|
||||||
|
data, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||||
|
return runtime.CallAPITyped(http.MethodGet,
|
||||||
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result := &minuteDetailItem{MinuteToken: minuteToken}
|
result := &minuteDetailItem{MinuteToken: minuteToken}
|
||||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
if isMinutesDetailProcessingError(err) {
|
||||||
|
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
|
||||||
|
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||||
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
||||||
} else {
|
} else {
|
||||||
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
||||||
@@ -81,10 +98,16 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
|||||||
needKeyword := runtime.Bool("keyword")
|
needKeyword := runtime.Bool("keyword")
|
||||||
|
|
||||||
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
||||||
artData, err := runtime.CallAPITyped(http.MethodGet,
|
artData, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
return runtime.CallAPITyped(http.MethodGet,
|
||||||
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
|
if isMinutesDetailProcessingError(err) {
|
||||||
|
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute artifacts are still being generated")
|
||||||
|
} else {
|
||||||
|
result.Error = fmt.Sprintf("failed to query minute artifacts: %v", err)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
artifacts := make(map[string]any)
|
artifacts := make(map[string]any)
|
||||||
if needSummary {
|
if needSummary {
|
||||||
@@ -133,6 +156,78 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isMinutesDetailProcessingError(err error) bool {
|
||||||
|
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailProcessingCode {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func minutesDetailWaitConfig(runtime *common.RuntimeContext) (time.Duration, time.Duration) {
|
||||||
|
timeoutSeconds, intervalSeconds := normalizeMinutesDetailWaitSeconds(runtime.Int("wait-timeout-seconds"), runtime.Int("wait-interval-seconds"))
|
||||||
|
return time.Duration(timeoutSeconds) * time.Second, time.Duration(intervalSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMinutesDetailWaitSeconds(timeoutSeconds, intervalSeconds int) (int, int) {
|
||||||
|
if timeoutSeconds <= 0 {
|
||||||
|
timeoutSeconds = minutesDetailWaitTimeoutDefault
|
||||||
|
}
|
||||||
|
if intervalSeconds <= 0 {
|
||||||
|
intervalSeconds = minutesDetailWaitIntervalDefault
|
||||||
|
}
|
||||||
|
return timeoutSeconds, intervalSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func callMinutesDetailAPIUntilReady(ctx context.Context, runtime *common.RuntimeContext, waitReady bool, timeout, interval time.Duration, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for {
|
||||||
|
data, err := call()
|
||||||
|
if err == nil || !waitReady || !isMinutesDetailProcessingError(err) {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
return nil, ctxErr
|
||||||
|
}
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 || interval > remaining {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "%s minute is still processing; retrying in %s\n", minutesDetailLogPrefix, interval)
|
||||||
|
timer := time.NewTimer(interval)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestedMinutesDetailArtifactFlags(runtime *common.RuntimeContext) []string {
|
||||||
|
var flags []string
|
||||||
|
for _, flag := range []string{"summary", "todo", "chapter", "keyword", "transcript"} {
|
||||||
|
if runtime.Bool(flag) {
|
||||||
|
flags = append(flags, "--"+flag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func markMinutesDetailProcessing(result *minuteDetailItem, minuteToken string, artifactFlags []string, reason string) {
|
||||||
|
result.Status = "processing"
|
||||||
|
result.Retryable = true
|
||||||
|
result.Error = reason
|
||||||
|
result.Hint = "The minute is still being generated. Retry later, or rerun the next_command to wait until it is ready."
|
||||||
|
result.NextCommand = minutesDetailNextCommand(minuteToken, artifactFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func minutesDetailNextCommand(minuteToken string, artifactFlags []string) string {
|
||||||
|
parts := []string{"lark-cli", "minutes", "+detail", "--minute-tokens", minuteToken}
|
||||||
|
parts = append(parts, artifactFlags...)
|
||||||
|
parts = append(parts, "--wait-ready")
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
|
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
|
||||||
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
|
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
|
||||||
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
|
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
|
||||||
@@ -201,6 +296,9 @@ var MinutesDetail = common.Shortcut{
|
|||||||
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
||||||
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
|
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
|
||||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
|
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
|
||||||
|
{Name: "wait-ready", Type: "bool", Desc: "wait until minute metadata/artifacts are ready", Hidden: true},
|
||||||
|
{Name: "wait-timeout-seconds", Type: "int", Default: "300", Desc: "maximum seconds to wait for readiness", Hidden: true},
|
||||||
|
{Name: "wait-interval-seconds", Type: "int", Default: "15", Desc: "seconds between readiness checks", Hidden: true},
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||||
@@ -282,8 +380,15 @@ var MinutesDetail = common.Shortcut{
|
|||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
||||||
if r.Error != "" {
|
if r.Error != "" {
|
||||||
row["status"] = "FAIL"
|
if r.Status == "processing" {
|
||||||
|
row["status"] = "PROCESSING"
|
||||||
|
} else {
|
||||||
|
row["status"] = "FAIL"
|
||||||
|
}
|
||||||
row["error"] = r.Error
|
row["error"] = r.Error
|
||||||
|
if r.NextCommand != "" {
|
||||||
|
row["next_command"] = r.NextCommand
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
row["status"] = "OK"
|
row["status"] = "OK"
|
||||||
row["title"] = r.Title
|
row["title"] = r.Title
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -108,6 +109,17 @@ func detailArtifactsStub(token, transcript string) *httpmock.Stub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detailProcessingStub(path string) *httpmock.Stub {
|
||||||
|
return &httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: path,
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 2091003,
|
||||||
|
"msg": "minute is processing",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
||||||
@@ -172,6 +184,34 @@ func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetail_HiddenWaitFlags(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
parent := &cobra.Command{Use: "minutes"}
|
||||||
|
MinutesDetail.Mount(parent, f)
|
||||||
|
parent.SetOut(stdout)
|
||||||
|
parent.SetArgs([]string{"+detail", "--help"})
|
||||||
|
parent.SilenceErrors = true
|
||||||
|
parent.SilenceUsage = true
|
||||||
|
if err := parent.Execute(); err != nil {
|
||||||
|
t.Fatalf("help failed: %v", err)
|
||||||
|
}
|
||||||
|
help := stdout.String()
|
||||||
|
for _, hidden := range []string{"wait-ready", "wait-timeout-seconds", "wait-interval-seconds"} {
|
||||||
|
if strings.Contains(help, hidden) {
|
||||||
|
t.Fatalf("hidden flag %q should not appear in help:\n%s", hidden, help)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.Reset()
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tok001", "--summary", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "0", "--wait-interval-seconds", "0", "--dry-run", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hidden wait flags should parse: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Execute tests with mocked HTTP
|
// Execute tests with mocked HTTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -355,6 +395,136 @@ func TestDetail_Execute_MinuteNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetail_Execute_MetadataProcessing(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokpending"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokpending", "--summary", "--as", "user"}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
var pfErr *output.PartialFailureError
|
||||||
|
if !errors.As(err, &pfErr) {
|
||||||
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["status"] != "processing" {
|
||||||
|
t.Fatalf("status = %v, want processing", m["status"])
|
||||||
|
}
|
||||||
|
if m["retryable"] != true {
|
||||||
|
t.Fatalf("retryable = %v, want true", m["retryable"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(fmt.Sprint(m["next_command"]), "minutes +detail --minute-tokens tokpending --summary --wait-ready") {
|
||||||
|
t.Fatalf("next_command = %v", m["next_command"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_Execute_ArtifactsProcessing(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailMinuteGetStub("tokartpending", "note_pending", "Pending Artifacts"))
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokartpending/artifacts"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokartpending", "--summary", "--todo", "--as", "user"}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["status"] != "processing" {
|
||||||
|
t.Fatalf("status = %v, want processing", m["status"])
|
||||||
|
}
|
||||||
|
if m["title"] != "Pending Artifacts" || m["note_id"] != "note_pending" {
|
||||||
|
t.Fatalf("metadata should be preserved on artifacts processing, got title=%v note_id=%v", m["title"], m["note_id"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(fmt.Sprint(m["next_command"]), "--summary --todo --wait-ready") {
|
||||||
|
t.Fatalf("next_command = %v", m["next_command"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_MetadataEventuallyReady(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitmeta"))
|
||||||
|
reg.Register(detailMinuteGetStub("tokwaitmeta", "", "Ready Metadata"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tokwaitmeta", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["title"] != "Ready Metadata" {
|
||||||
|
t.Fatalf("title = %v, want Ready Metadata", m["title"])
|
||||||
|
}
|
||||||
|
if _, ok := m["artifacts"]; ok {
|
||||||
|
t.Fatal("artifacts should not be fetched without artifact flags")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_ArtifactsEventuallyReady(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailMinuteGetStub("tokwaitart", "note_wait", "Ready Artifacts"))
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitart/artifacts"))
|
||||||
|
reg.Register(detailArtifactsStub("tokwaitart", ""))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tokwaitart", "--summary", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
arts, _ := m["artifacts"].(map[string]any)
|
||||||
|
if arts == nil {
|
||||||
|
t.Fatal("expected artifacts")
|
||||||
|
}
|
||||||
|
if arts["summary"] != "Test summary content" {
|
||||||
|
t.Fatalf("summary = %v", arts["summary"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_TimeoutUsesProcessingResult(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailMinuteGetStub("toktimeout", "note_timeout", "Timeout Artifacts"))
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/toktimeout/artifacts"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "toktimeout", "--summary", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "1", "--wait-interval-seconds", "2", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["status"] != "processing" || m["title"] != "Timeout Artifacts" || m["note_id"] != "note_timeout" {
|
||||||
|
t.Fatalf("timeout should preserve processing status and metadata, got %+v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_DoesNotPollNonProcessingErrors(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
var callCount int
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/minutes/v1/minutes/tokmissing",
|
||||||
|
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
|
||||||
|
Reusable: true,
|
||||||
|
OnMatch: func(req *http.Request) { callCount++ },
|
||||||
|
})
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tokmissing", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
if callCount != 1 {
|
||||||
|
t.Fatalf("non-processing error should not be retried, callCount=%d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Pure function tests
|
// Pure function tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -378,6 +548,36 @@ func TestValidMinuteTokenDetail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMinutesDetailWaitSeconds(t *testing.T) {
|
||||||
|
timeout, interval := normalizeMinutesDetailWaitSeconds(0, 0)
|
||||||
|
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||||
|
t.Fatalf("normalize(0,0) = (%d,%d), want defaults (%d,%d)", timeout, interval, minutesDetailWaitTimeoutDefault, minutesDetailWaitIntervalDefault)
|
||||||
|
}
|
||||||
|
timeout, interval = normalizeMinutesDetailWaitSeconds(-1, -2)
|
||||||
|
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||||
|
t.Fatalf("normalize(negative) = (%d,%d), want defaults", timeout, interval)
|
||||||
|
}
|
||||||
|
timeout, interval = normalizeMinutesDetailWaitSeconds(9, 3)
|
||||||
|
if timeout != 9 || interval != 3 {
|
||||||
|
t.Fatalf("normalize(9,3) = (%d,%d)", timeout, interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstDetailMinute(t *testing.T, raw []byte) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||||
|
t.Fatalf("failed to parse output: %v\n%s", err, string(raw))
|
||||||
|
}
|
||||||
|
data, _ := resp["data"].(map[string]any)
|
||||||
|
minutes, _ := data["minutes"].([]any)
|
||||||
|
if len(minutes) != 1 {
|
||||||
|
t.Fatalf("expected 1 minute, got %d in %s", len(minutes), string(raw))
|
||||||
|
}
|
||||||
|
m, _ := minutes[0].(map[string]any)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// chdirForDetailTest switches cwd to a temp dir for the test.
|
// chdirForDetailTest switches cwd to a temp dir for the test.
|
||||||
func chdirForDetailTest(t *testing.T) string {
|
func chdirForDetailTest(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
|
|||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||||
dr := common.NewDryRunAPI()
|
return common.NewDryRunAPI().
|
||||||
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
|
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||||
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
|
|
||||||
}
|
|
||||||
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
|
||||||
Body(buildSpeakerReplaceRequestBody(runtime))
|
Body(buildSpeakerReplaceRequestBody(runtime))
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||||
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||||
|
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||||
|
|
||||||
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
|
_, err := runtime.CallAPITyped(http.MethodPut,
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = runtime.CallAPITyped(http.MethodPut,
|
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
||||||
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
|
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||||
out := map[string]interface{}{
|
out := map[string]interface{}{
|
||||||
"minute_token": minuteToken,
|
"minute_token": minuteToken,
|
||||||
"to_user_id": toUserID,
|
"to_user_id": toUserID,
|
||||||
}
|
}
|
||||||
if fromSpeakerID != "" {
|
if fromSpeakerID != "" {
|
||||||
out["from_speaker_id"] = fromSpeakerID
|
out["from_speaker_id"] = fromSpeakerID
|
||||||
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
|
|
||||||
out["from_speaker_input"] = fromSpeakerInput
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
out["from_user_id"] = fromUserID
|
out["from_user_id"] = fromUserID
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
|
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
|
||||||
if fromSpeakerInput != "" {
|
|
||||||
return fromSpeakerInput
|
|
||||||
}
|
|
||||||
if fromSpeakerID != "" {
|
if fromSpeakerID != "" {
|
||||||
return fromSpeakerID
|
return fromSpeakerID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
||||||
warmTokenCache(t)
|
|
||||||
|
|
||||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
|
||||||
"+speaker-replace",
|
|
||||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
|
||||||
"--from-speaker-id", "说话人1",
|
|
||||||
"--to-user-id", "ou_new_speaker",
|
|
||||||
"--dry-run", "--as", "user",
|
|
||||||
}, f, stdout)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := stdout.String()
|
|
||||||
if !strings.Contains(out, "GET") {
|
|
||||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "/transcript/speakerlist") {
|
|
||||||
t.Errorf("expected speakerlist path, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "PUT") {
|
|
||||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "ou_new_speaker") {
|
|
||||||
t.Errorf("expected to_user_id in body, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
warmTokenCache(t)
|
warmTokenCache(t)
|
||||||
|
|
||||||
reg.Register(&httpmock.Stub{
|
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
|
||||||
Method: http.MethodGet,
|
// straight through without a second speakerlist call. If the code still
|
||||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
|
// prefetched speakerlist, the unregistered GET would fail the request.
|
||||||
Body: map[string]interface{}{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "ok",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"speakers": []interface{}{
|
|
||||||
map[string]interface{}{
|
|
||||||
"speaker_id": "ENCRYPTED_TOKEN_ABC",
|
|
||||||
"name": "说话人1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
||||||
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|||||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||||
"+speaker-replace",
|
"+speaker-replace",
|
||||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||||
"--from-speaker-id", "说话人1",
|
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
|
||||||
"--to-user-id", "ou_new_speaker",
|
"--to-user-id", "ou_new_speaker",
|
||||||
"--format", "json", "--as", "user",
|
"--format", "json", "--as", "user",
|
||||||
}, f, stdout)
|
}, f, stdout)
|
||||||
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|||||||
|
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
MinuteToken string `json:"minute_token"`
|
FromSpeakerID string `json:"from_speaker_id"`
|
||||||
FromSpeakerInput string `json:"from_speaker_input"`
|
ToUserID string `json:"to_user_id"`
|
||||||
FromSpeakerID string `json:"from_speaker_id"`
|
|
||||||
ToUserID string `json:"to_user_id"`
|
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||||
t.Fatalf("unmarshal stdout: %v", err)
|
t.Fatalf("unmarshal stdout: %v", err)
|
||||||
}
|
}
|
||||||
if envelope.Data.FromSpeakerInput != "说话人1" {
|
|
||||||
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
|
|
||||||
}
|
|
||||||
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
||||||
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
||||||
}
|
}
|
||||||
|
if envelope.Data.ToUserID != "ou_new_speaker" {
|
||||||
|
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||||
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := stdout.String()
|
out := stdout.String()
|
||||||
if !strings.Contains(out, "GET") {
|
if strings.Contains(out, "/transcript/speakerlist") {
|
||||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "PUT") {
|
||||||
|
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
||||||
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package minutes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
|
||||||
)
|
|
||||||
|
|
||||||
type minuteSpeaker struct {
|
|
||||||
SpeakerID string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
|
|
||||||
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
|
|
||||||
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if data == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
items := common.GetSlice(data, "speakers")
|
|
||||||
speakers := make([]minuteSpeaker, 0, len(items))
|
|
||||||
for _, raw := range items {
|
|
||||||
item, _ := raw.(map[string]interface{})
|
|
||||||
if item == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
|
|
||||||
name := strings.TrimSpace(common.GetString(item, "name"))
|
|
||||||
if id == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
|
|
||||||
}
|
|
||||||
return speakers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
var matches []minuteSpeaker
|
|
||||||
for _, s := range speakers {
|
|
||||||
if s.Name == name {
|
|
||||||
matches = append(matches, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch len(matches) {
|
|
||||||
case 0:
|
|
||||||
return "", errs.NewValidationError(errs.SubtypeNotFound,
|
|
||||||
"no speaker named %q in minute transcript", name).
|
|
||||||
WithParam("--from-speaker-id").
|
|
||||||
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
|
|
||||||
case 1:
|
|
||||||
return matches[0].SpeakerID, nil
|
|
||||||
default:
|
|
||||||
ids := make([]string, len(matches))
|
|
||||||
for i, m := range matches {
|
|
||||||
ids[i] = m.SpeakerID
|
|
||||||
}
|
|
||||||
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
|
||||||
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
|
|
||||||
WithParam("--from-speaker-id").
|
|
||||||
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
|
|
||||||
// The input may already be an opaque speaker_id, or a display name that requires
|
|
||||||
// an internal speaker-list fetch.
|
|
||||||
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
|
|
||||||
input = strings.TrimSpace(input)
|
|
||||||
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
for _, s := range speakers {
|
|
||||||
if s.SpeakerID == input {
|
|
||||||
return input, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resolveSpeakerIDByName(speakers, input)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
|
|
||||||
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
|
|
||||||
if fromUserID != "" {
|
|
||||||
return "", fromUserID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
|
|
||||||
return fromSpeakerID, "", err
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package minutes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestResolveSpeakerIDByName(t *testing.T) {
|
|
||||||
speakers := []minuteSpeaker{
|
|
||||||
{SpeakerID: "id_a", Name: "Alice"},
|
|
||||||
{SpeakerID: "id_b", Name: "Bob"},
|
|
||||||
{SpeakerID: "id_c", Name: "Alice"},
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := resolveSpeakerIDByName(speakers, "Bob")
|
|
||||||
if err != nil || id != "id_b" {
|
|
||||||
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = resolveSpeakerIDByName(speakers, "Carol")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected not found error")
|
|
||||||
}
|
|
||||||
var ve *errs.ValidationError
|
|
||||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
|
|
||||||
t.Fatalf("want not-found validation error, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = resolveSpeakerIDByName(speakers, "Alice")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected duplicate name error")
|
|
||||||
}
|
|
||||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
|
|
||||||
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
|
|
||||||
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,8 @@ package minutes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
@@ -65,8 +67,25 @@ var MinutesUpload = common.Shortcut{
|
|||||||
outData := map[string]interface{}{
|
outData := map[string]interface{}{
|
||||||
"minute_url": minuteURL,
|
"minute_url": minuteURL,
|
||||||
}
|
}
|
||||||
|
if minuteToken := extractUploadedMinuteToken(minuteURL); minuteToken != "" {
|
||||||
|
outData["minute_token"] = minuteToken
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(outData, nil, nil)
|
runtime.OutFormat(outData, nil, nil)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractUploadedMinuteToken(minuteURL string) string {
|
||||||
|
u, err := url.Parse(minuteURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "minutes" && i+1 < len(parts) {
|
||||||
|
return parts[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,4 +143,28 @@ func TestMinutesUpload_Execute(t *testing.T) {
|
|||||||
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
||||||
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
|
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
|
||||||
}
|
}
|
||||||
|
if dataMap["minute_token"] != "obcnq3b9jl72l83w4f149w9c" {
|
||||||
|
t.Errorf("expected minute_token obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_token"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractUploadedMinuteToken(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "standard", url: "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c", want: "obcnq3b9jl72l83w4f149w9c"},
|
||||||
|
{name: "query", url: "https://sample.feishu.cn/minutes/obcn123?from=upload", want: "obcn123"},
|
||||||
|
{name: "trailing slash", url: "https://sample.feishu.cn/minutes/obcn123/", want: "obcn123"},
|
||||||
|
{name: "invalid", url: "://bad", want: ""},
|
||||||
|
{name: "no minutes path", url: "https://sample.feishu.cn/docx/abc", want: ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := extractUploadedMinuteToken(tt.url); got != tt.want {
|
||||||
|
t.Fatalf("extractUploadedMinuteToken(%q) = %q, want %q", tt.url, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,18 +205,16 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
|
|||||||
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
|
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sheets backward-compatibility grouping.
|
// Sheets backward-compatibility help grouping.
|
||||||
//
|
//
|
||||||
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that
|
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that
|
||||||
// users whose lark-sheets skill predates the refactor keep working even after
|
// users whose lark-sheets skill predates the refactor keep working even after
|
||||||
// upgrading only the binary. applySheetsCompatGroups tags each alias into a
|
// upgrading only the binary. In `sheets --help` those aliases would otherwise
|
||||||
// dedicated deprecated cobra group. The refactored commands have been the
|
// sort alphabetically into the same flat list as the current commands,
|
||||||
// default for over a month, so `sheets --help` no longer lists these aliases:
|
// indistinguishable from them. applySheetsCompatGroups splits them into a
|
||||||
// sheetsUsageTemplate renders every group except the deprecated one. The
|
// dedicated cobra group whose heading tells the user to update their skill, and
|
||||||
// grouping is still applied for two reasons — the unknown-subcommand path
|
// appends a "(→ +new-command)" pointer to each alias so the migration target is
|
||||||
// (cmd/root.go) keys off it to classify a mistyped legacy alias, and each
|
// obvious. Pure presentation — the aliases stay fully executable.
|
||||||
// alias's own `sheets <alias> --help` still surfaces the "(→ +new-command)"
|
|
||||||
// migration pointer appended below. The aliases stay fully executable.
|
|
||||||
const (
|
const (
|
||||||
sheetsCurrentGroupID = "sheets-current"
|
sheetsCurrentGroupID = "sheets-current"
|
||||||
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
|
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
|
||||||
@@ -226,10 +224,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current
|
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current
|
||||||
// command(s) that replace it, shown as a "(→ ...)" suffix in the alias's own
|
// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases
|
||||||
// --help and reused by wrapSheetsBackwardDeprecation for the on-execution
|
// absent from this map still land in the deprecated group, just without a
|
||||||
// _notice. Aliases absent from this map still land in the deprecated group,
|
// pointer, so a missing entry degrades gracefully rather than misgrouping.
|
||||||
// just without a pointer, so a missing entry degrades gracefully.
|
|
||||||
var sheetsAliasReplacement = map[string]string{
|
var sheetsAliasReplacement = map[string]string{
|
||||||
// spreadsheet / sheet management
|
// spreadsheet / sheet management
|
||||||
"+create": "+workbook-create",
|
"+create": "+workbook-create",
|
||||||
@@ -282,43 +279,6 @@ var sheetsAliasReplacement = map[string]string{
|
|||||||
"+delete-float-image": "+float-image-delete",
|
"+delete-float-image": "+float-image-delete",
|
||||||
}
|
}
|
||||||
|
|
||||||
// sheetsUsageTemplate is cobra v1.10.2's stock usage template with a single
|
|
||||||
// change: the group loop is guarded by {{if ne $group.ID "deprecated"}} so the
|
|
||||||
// deprecated pre-refactor aliases are omitted from `sheets --help` altogether.
|
|
||||||
// Everything else — current commands, ungrouped metaapi subcommands under
|
|
||||||
// "Additional Commands", flags — renders exactly as cobra's default. Keep in
|
|
||||||
// sync with cobra's defaultUsageTemplate on upgrade.
|
|
||||||
var sheetsUsageTemplate = fmt.Sprintf(`Usage:{{if .Runnable}}
|
|
||||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
|
||||||
{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
|
|
||||||
|
|
||||||
Aliases:
|
|
||||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
|
||||||
|
|
||||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
|
||||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}{{if ne $group.ID %q}}
|
|
||||||
|
|
||||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
|
||||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
|
||||||
|
|
||||||
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
|
||||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
|
||||||
|
|
||||||
Flags:
|
|
||||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
|
||||||
|
|
||||||
Global Flags:
|
|
||||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
|
||||||
|
|
||||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
|
||||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
|
||||||
|
|
||||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
|
|
||||||
`, sheetsDeprecatedGroupID)
|
|
||||||
|
|
||||||
func applySheetsCompatGroups(svc *cobra.Command) {
|
func applySheetsCompatGroups(svc *cobra.Command) {
|
||||||
svc.AddGroup(
|
svc.AddGroup(
|
||||||
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
|
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
|
||||||
@@ -350,11 +310,6 @@ func applySheetsCompatGroups(svc *cobra.Command) {
|
|||||||
c.GroupID = sheetsCurrentGroupID
|
c.GroupID = sheetsCurrentGroupID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refactored commands have been the default for over a month: drop the
|
|
||||||
// deprecated group from `sheets --help` (see sheetsUsageTemplate). The
|
|
||||||
// aliases remain grouped and executable, just no longer advertised here.
|
|
||||||
svc.SetUsageTemplate(sheetsUsageTemplate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets
|
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets
|
||||||
|
|||||||
@@ -170,6 +170,27 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
|
||||||
|
program := &cobra.Command{Use: "root"}
|
||||||
|
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||||
|
|
||||||
|
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
|
||||||
|
cmd, _, err := program.Find([]string{"docs", name})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("find docs %s shortcut: %v", name, err)
|
||||||
|
}
|
||||||
|
if cmd == nil || cmd.Name() != name {
|
||||||
|
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
|
||||||
|
}
|
||||||
|
if cmd.Flags().Lookup("api-version") != nil {
|
||||||
|
t.Fatalf("docs %s should not expose --api-version", name)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
|
||||||
|
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
|
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
|
||||||
program := &cobra.Command{Use: "root"}
|
program := &cobra.Command{Use: "root"}
|
||||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||||
@@ -511,11 +532,10 @@ func TestApplySheetsCompatGroups(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// End-to-end: `sheets --help` must list refactored shortcuts under Available
|
// End-to-end: the rendered `sheets --help` must surface the deprecated-group
|
||||||
// Commands, but no longer advertise the deprecated pre-refactor aliases or the
|
// heading (telling users to update their skill) plus the per-alias migration
|
||||||
// deprecated group heading (sheetsUsageTemplate skips that group). The aliases
|
// pointers, while keeping the refactored shortcuts under Available Commands.
|
||||||
// stay registered and executable — hidden from the parent listing, not removed.
|
func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
|
||||||
func TestRegisterShortcutsSheetsHelpHidesDeprecatedAliases(t *testing.T) {
|
|
||||||
program := &cobra.Command{Use: "root"}
|
program := &cobra.Command{Use: "root"}
|
||||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||||
|
|
||||||
@@ -531,25 +551,19 @@ func TestRegisterShortcutsSheetsHelpHidesDeprecatedAliases(t *testing.T) {
|
|||||||
}
|
}
|
||||||
got := out.String()
|
got := out.String()
|
||||||
|
|
||||||
for _, want := range []string{"Available Commands:", "+cells-get"} {
|
for _, want := range []string{
|
||||||
if !strings.Contains(got, want) {
|
"Available Commands:",
|
||||||
t.Fatalf("sheets help missing %q:\n%s", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, unwanted := range []string{
|
|
||||||
"Deprecated pre-refactor commands",
|
"Deprecated pre-refactor commands",
|
||||||
"update your lark-sheets skill",
|
"update your lark-sheets skill",
|
||||||
"+read",
|
"+read",
|
||||||
|
"(→ +cells-get)",
|
||||||
"+write",
|
"+write",
|
||||||
|
"(→ +cells-set)",
|
||||||
} {
|
} {
|
||||||
if strings.Contains(got, unwanted) {
|
if !strings.Contains(got, want) {
|
||||||
t.Fatalf("sheets help still shows deprecated content %q:\n%s", unwanted, got)
|
t.Fatalf("sheets help missing %q:\n%s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if alias, _, ferr := sheetsCmd.Find([]string{"+read"}); ferr != nil || alias == nil {
|
|
||||||
t.Fatalf("deprecated alias +read should stay registered, got err=%v cmd=%v", ferr, alias)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that
|
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package backward
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,30 +17,20 @@ import (
|
|||||||
|
|
||||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
// synthetic token prefixed with "fake_office_" and the backend requires
|
||||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
// "office_sheet_file" instead.
|
||||||
const (
|
const (
|
||||||
sheetImageParentType = "sheet_image"
|
sheetImageParentType = "sheet_image"
|
||||||
officeSheetFileParentType = "office_sheet_file"
|
officeSheetFileParentType = "office_sheet_file"
|
||||||
fakeOfficeTokenPrefix = "fake_office_"
|
fakeOfficeTokenPrefix = "fake_office_"
|
||||||
localOfficeTokenPrefix = "local_office_"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
|
|
||||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
|
||||||
// "local_office_"; accept either so image uploads keep working across the
|
|
||||||
// rename.
|
|
||||||
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
|
|
||||||
|
|
||||||
// sheetMediaParentType returns the drive media parent_type to use when
|
// sheetMediaParentType returns the drive media parent_type to use when
|
||||||
// uploading an image whose parent_node is spreadsheetToken, mapping either the
|
// uploading an image whose parent_node is spreadsheetToken, mapping the
|
||||||
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
|
// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file".
|
||||||
// "office_sheet_file".
|
|
||||||
func sheetMediaParentType(spreadsheetToken string) string {
|
func sheetMediaParentType(spreadsheetToken string) string {
|
||||||
for _, prefix := range officeTokenPrefixes {
|
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
|
||||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
return officeSheetFileParentType
|
||||||
return officeSheetFileParentType
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return sheetImageParentType
|
return sheetImageParentType
|
||||||
}
|
}
|
||||||
@@ -146,8 +135,7 @@ func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath strin
|
|||||||
stat, err := runtime.FileIO().Stat(filePath)
|
stat, err := runtime.FileIO().Stat(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
wrapped := common.WrapInputStatErrorTyped(err, "file not found")
|
wrapped := common.WrapInputStatErrorTyped(err, "file not found")
|
||||||
var v *errs.ValidationError
|
if v, ok := wrapped.(*errs.ValidationError); ok {
|
||||||
if errors.As(wrapped, &v) {
|
|
||||||
return "", nil, v.WithParam("--file")
|
return "", nil, v.WithParam("--file")
|
||||||
}
|
}
|
||||||
return "", nil, wrapped
|
return "", nil, wrapped
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
|||||||
{
|
{
|
||||||
shortcut: "+rows-resize",
|
shortcut: "+rows-resize",
|
||||||
sc: RowsResize,
|
sc: RowsResize,
|
||||||
args: []string{"--sheet-id", "sh1", "--range", "1", "--height", "30"},
|
args: []string{"--sheet-id", "sh1", "--range", "1", "--type", "pixel", "--size", "30"},
|
||||||
subInput: `{"sheet-id":"sh1","range":"1","height":30}`,
|
subInput: `{"sheet-id":"sh1","range":"1","type":"pixel","size":30}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shortcut: "+cols-resize",
|
shortcut: "+cols-resize",
|
||||||
@@ -409,12 +409,12 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
|||||||
wantContains: "--count must be > 0",
|
wantContains: "--count must be > 0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "+rows-resize --height with --type standard",
|
name: "+rows-resize --type pixel without --size",
|
||||||
shortcut: RowsResize,
|
shortcut: RowsResize,
|
||||||
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--height", "30", "--type", "standard"},
|
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"},
|
||||||
subShortcut: "+rows-resize",
|
subShortcut: "+rows-resize",
|
||||||
subInput: `{"sheet-id":"sh1","range":"1:2","height":30,"type":"standard"}`,
|
subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`,
|
||||||
wantContains: "--height cannot be combined with --type standard",
|
wantContains: "--type pixel requires --size",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "+sheet-delete missing sheet selector",
|
name: "+sheet-delete missing sheet selector",
|
||||||
@@ -469,34 +469,6 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBatchOp_RejectsResizeMapForm locks the nesting guard: the map form
|
|
||||||
// (--widths/--heights) expands into its own batch_update, and batch_update
|
|
||||||
// cannot nest, so a +batch-update sub-op carrying `widths`/`heights` must be
|
|
||||||
// rejected with a pointer to the standalone form — it is standalone-valid,
|
|
||||||
// so this case cannot live in the standalone-vs-batch equivalence table.
|
|
||||||
func TestBatchOp_RejectsResizeMapForm(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
cases := []struct {
|
|
||||||
shortcut string
|
|
||||||
input string
|
|
||||||
}{
|
|
||||||
{"+cols-resize", `{"sheet-id":"sh1","widths":{"A":100}}`},
|
|
||||||
{"+rows-resize", `{"sheet-id":"sh1","heights":{"1":50}}`},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.shortcut, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
var subInput map[string]interface{}
|
|
||||||
if err := json.Unmarshal([]byte(tc.input), &subInput); err != nil {
|
|
||||||
t.Fatalf("bad input JSON: %v", err)
|
|
||||||
}
|
|
||||||
rawOp := map[string]interface{}{"shortcut": tc.shortcut, "input": subInput}
|
|
||||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
|
||||||
requireValidation(t, err, "not supported inside +batch-update")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBatchOp_RejectsWrongScalarType locks the type-check that closes the
|
// TestBatchOp_RejectsWrongScalarType locks the type-check that closes the
|
||||||
// silent-coercion gap: `operations` skips parse-time schema validation, and
|
// silent-coercion gap: `operations` skips parse-time schema validation, and
|
||||||
// mapFlagView coerces a mismatched scalar to its zero value, so a sub-op field
|
// mapFlagView coerces a mismatched scalar to its zero value, so a sub-op field
|
||||||
@@ -639,10 +611,10 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
|
|||||||
"--position is required",
|
"--position is required",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"+rows-resize missing both --height and --type",
|
"+rows-resize missing --type",
|
||||||
"+rows-resize",
|
"+rows-resize",
|
||||||
`{"sheet-id":"sh1","range":"1:1"}`,
|
`{"sheet-id":"sh1","range":"1:1"}`,
|
||||||
"give --height <px> for a pixel size, or --type standard / auto",
|
"--type is required",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"+range-copy missing --target-range",
|
"+range-copy missing --target-range",
|
||||||
@@ -830,7 +802,7 @@ func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) {
|
|||||||
// bare single-element ranges.
|
// bare single-element ranges.
|
||||||
body = parseDryRunBody(t, BatchUpdate, []string{
|
body = parseDryRunBody(t, BatchUpdate, []string{
|
||||||
"--url", testURL,
|
"--url", testURL,
|
||||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","height":40}}]`,
|
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","type":"pixel","size":40}}]`,
|
||||||
"--yes",
|
"--yes",
|
||||||
})
|
})
|
||||||
ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
|
ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package sheets
|
package sheets
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,19 +118,10 @@ var batchOpDispatch = map[string]batchOpMapping{
|
|||||||
}},
|
}},
|
||||||
|
|
||||||
// ─── 行高列宽 (resize_range, 无 operation 字段) ─────────────────
|
// ─── 行高列宽 (resize_range, 无 operation 字段) ─────────────────
|
||||||
// The map form (--heights/--widths) fans out into its own batch_update
|
|
||||||
// and cannot nest inside +batch-update; sub-ops must use the uniform
|
|
||||||
// single-range form (range + height/width or type).
|
|
||||||
"+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
"+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||||
if err := rejectResizeMapInBatch(fv, "row"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return resizeInput(fv, token, sid, sname, "row")
|
return resizeInput(fv, token, sid, sname, "row")
|
||||||
}},
|
}},
|
||||||
"+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
"+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||||
if err := rejectResizeMapInBatch(fv, "column"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return resizeInput(fv, token, sid, sname, "column")
|
return resizeInput(fv, token, sid, sname, "column")
|
||||||
}},
|
}},
|
||||||
|
|
||||||
@@ -207,54 +197,6 @@ var batchOpDispatch = map[string]batchOpMapping{
|
|||||||
"+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)},
|
"+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)},
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedBatchShortcuts lists every shortcut accepted inside +batch-update,
|
|
||||||
// sorted, for the not-allowed error hint.
|
|
||||||
func allowedBatchShortcuts() []string {
|
|
||||||
out := make([]string, 0, len(batchOpDispatch))
|
|
||||||
for sc := range batchOpDispatch {
|
|
||||||
out = append(out, sc)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// subOpInputContract renders one shortcut's complete sub-op key vocabulary
|
|
||||||
// (wire-style underscore names) for the translator-failure hint: required
|
|
||||||
// flags are marked, the sheet selector pair collapses to a choose-one, and
|
|
||||||
// spreadsheet locators are omitted (reserved for the batch top level).
|
|
||||||
// Returns "" for shortcuts without a flag-defs entry.
|
|
||||||
func subOpInputContract(sc string) string {
|
|
||||||
defs, _ := loadFlagDefs()
|
|
||||||
spec, ok := defs[sc]
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
idFlag, nameFlag := sheetSelectorFlagsForSubOp(sc)
|
|
||||||
var keys []string
|
|
||||||
sheetSelector := ""
|
|
||||||
for _, df := range spec.Flags {
|
|
||||||
if df.Kind == "system" || df.Hidden {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch df.Name {
|
|
||||||
case "url", "spreadsheet-token":
|
|
||||||
continue // reserved: supplied by +batch-update top level
|
|
||||||
case idFlag, nameFlag:
|
|
||||||
sheetSelector = strings.ReplaceAll(idFlag, "-", "_") + "|" + strings.ReplaceAll(nameFlag, "-", "_") + " (choose one)"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.ReplaceAll(df.Name, "-", "_")
|
|
||||||
if df.Required == "required" {
|
|
||||||
key += " (required)"
|
|
||||||
}
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
if sheetSelector != "" {
|
|
||||||
keys = append([]string{sheetSelector}, keys...)
|
|
||||||
}
|
|
||||||
return strings.Join(keys, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// rejectLocalImageInBatch blocks the local-file --image source inside
|
// rejectLocalImageInBatch blocks the local-file --image source inside
|
||||||
// +batch-update: a batch sub-op has no upload phase, so the file could not be
|
// +batch-update: a batch sub-op has no upload phase, so the file could not be
|
||||||
// turned into a file_token. Callers must pass --image-token / --image-uri.
|
// turned into a file_token. Callers must pass --image-token / --image-uri.
|
||||||
@@ -320,8 +262,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
|||||||
}
|
}
|
||||||
scRaw, present := op["shortcut"]
|
scRaw, present := op["shortcut"]
|
||||||
if !present {
|
if !present {
|
||||||
return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index).
|
return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index)
|
||||||
WithHint(`each entry must look like {"shortcut":"+cells-set","input":{"sheet_name":"…","range":"A1:B2","cells":[[…]]}} — input uses the shortcut's own flag names`)
|
|
||||||
}
|
}
|
||||||
sc, ok := scRaw.(string)
|
sc, ok := scRaw.(string)
|
||||||
if !ok || sc == "" {
|
if !ok || sc == "" {
|
||||||
@@ -329,15 +270,13 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
|||||||
}
|
}
|
||||||
mapping, ok := batchOpDispatch[sc]
|
mapping, ok := batchOpDispatch[sc]
|
||||||
if !ok {
|
if !ok {
|
||||||
// Inline the full allow-list: an agent that guessed a read op or a
|
|
||||||
// fan-out wrapper can pick the right shortcut immediately instead of
|
|
||||||
// spending a --print-schema round trip on the operations enum.
|
|
||||||
return nil, sheetsValidationForFlag(
|
return nil, sheetsValidationForFlag(
|
||||||
"operations",
|
"operations",
|
||||||
"operations[%d]: shortcut %q not allowed in +batch-update "+
|
"operations[%d]: shortcut %q not allowed in +batch-update "+
|
||||||
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
|
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+
|
||||||
|
"run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)",
|
||||||
index, sc,
|
index, sc,
|
||||||
).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", "))
|
)
|
||||||
}
|
}
|
||||||
inputRaw, hasInput := op["input"]
|
inputRaw, hasInput := op["input"]
|
||||||
var input map[string]interface{}
|
var input map[string]interface{}
|
||||||
@@ -385,14 +324,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
|||||||
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
|
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
|
||||||
body, err := mapping.translate(fv, token, sheetID, sheetName)
|
body, err := mapping.translate(fv, token, sheetID, sheetName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The inner error names one problem at a time (first missing flag);
|
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||||
// the hint lists the sub-op's complete key contract so an agent fixes
|
|
||||||
// every gap in a single retry instead of iterating flag by flag.
|
|
||||||
verr := sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
|
||||||
if contract := subOpInputContract(sc); contract != "" {
|
|
||||||
verr = verr.WithHint("%s input keys: %s", sc, contract)
|
|
||||||
}
|
|
||||||
return nil, verr
|
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"tool_name": mapping.mcpToolName,
|
"tool_name": mapping.mcpToolName,
|
||||||
@@ -400,23 +332,11 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxBatchOperations caps how many sub-operations a single +batch-update may
|
|
||||||
// carry. Every translated op (with its own cells/properties payload) is held in
|
|
||||||
// the out slice at once before the whole batch is marshaled, so an unbounded
|
|
||||||
// operation count is the same unbounded-materialization hazard as the fan-out
|
|
||||||
// matrix, on the operations axis.
|
|
||||||
const maxBatchOperations = 100
|
|
||||||
|
|
||||||
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
||||||
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
||||||
if len(rawOps) == 0 {
|
if len(rawOps) == 0 {
|
||||||
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
|
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
|
||||||
}
|
}
|
||||||
if len(rawOps) > maxBatchOperations {
|
|
||||||
batches := (len(rawOps) + maxBatchOperations - 1) / maxBatchOperations
|
|
||||||
return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps)).
|
|
||||||
WithHint("split the operations into %d separate +batch-update calls of at most %d entries each", batches, maxBatchOperations)
|
|
||||||
}
|
|
||||||
out := make([]interface{}, 0, len(rawOps))
|
out := make([]interface{}, 0, len(rawOps))
|
||||||
for i, raw := range rawOps {
|
for i, raw := range rawOps {
|
||||||
translated, err := translateBatchOp(raw, token, i)
|
translated, err := translateBatchOp(raw, token, i)
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package sheets
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// +csv-put lets a piped CSV satisfy an omitted --csv: agents routinely redirect
|
|
||||||
// a file into stdin but forget the `--csv -`. PostMount relaxes the required
|
|
||||||
// gate and installs a PreRunE that, when stdin is a non-interactive pipe,
|
|
||||||
// defaults an absent --csv to "-" so the standard stdin path reads it. On an
|
|
||||||
// interactive terminal the fallback stays off so the command never blocks.
|
|
||||||
func mountCsvPut(t *testing.T) *cobra.Command {
|
|
||||||
t.Helper()
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
parent := &cobra.Command{Use: "sheets"}
|
|
||||||
CsvPut.Mount(parent, f)
|
|
||||||
cmd, _, err := parent.Find([]string{"+csv-put"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Find(+csv-put) error = %v", err)
|
|
||||||
}
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func csvRequiredAnnotationPresent(cmd *cobra.Command) bool {
|
|
||||||
fl := cmd.Flags().Lookup("csv")
|
|
||||||
if fl == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, ok := fl.Annotations[cobra.BashCompOneRequiredFlag]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// withStdinIsPipe swaps the package-level pipe detector for the duration of a
|
|
||||||
// test so behavior does not depend on the real process stdin.
|
|
||||||
func withStdinIsPipe(t *testing.T, piped bool) {
|
|
||||||
t.Helper()
|
|
||||||
prev := csvPutStdinIsPipe
|
|
||||||
csvPutStdinIsPipe = func() bool { return piped }
|
|
||||||
t.Cleanup(func() { csvPutStdinIsPipe = prev })
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCsvPutPostMount_RelaxesCsvRequired(t *testing.T) {
|
|
||||||
cmd := mountCsvPut(t)
|
|
||||||
if csvRequiredAnnotationPresent(cmd) {
|
|
||||||
t.Error("--csv required annotation should be relaxed so csvPutInput reports the typed error")
|
|
||||||
}
|
|
||||||
if cmd.PreRunE == nil {
|
|
||||||
t.Fatal("PostMount should install a PreRunE for the stdin fallback")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCsvPutPreRunE_PipedAbsentDefaultsToDash(t *testing.T) {
|
|
||||||
withStdinIsPipe(t, true)
|
|
||||||
cmd := mountCsvPut(t)
|
|
||||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
|
||||||
t.Fatalf("PreRunE error = %v", err)
|
|
||||||
}
|
|
||||||
if got, _ := cmd.Flags().GetString("csv"); got != "-" {
|
|
||||||
t.Errorf("csv = %q, want %q (piped + absent should default to '-')", got, "-")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCsvPutPreRunE_InteractiveAbsentStaysEmpty(t *testing.T) {
|
|
||||||
withStdinIsPipe(t, false)
|
|
||||||
cmd := mountCsvPut(t)
|
|
||||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
|
||||||
t.Fatalf("PreRunE error = %v", err)
|
|
||||||
}
|
|
||||||
if got, _ := cmd.Flags().GetString("csv"); got != "" {
|
|
||||||
t.Errorf("csv = %q, want empty (interactive stdin must not be consumed / must not hang)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCsvPutPreRunE_ExplicitValueUnchanged(t *testing.T) {
|
|
||||||
withStdinIsPipe(t, true)
|
|
||||||
cmd := mountCsvPut(t)
|
|
||||||
if err := cmd.Flags().Set("csv", "x,y"); err != nil {
|
|
||||||
t.Fatalf("Set(csv) error = %v", err)
|
|
||||||
}
|
|
||||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
|
||||||
t.Fatalf("PreRunE error = %v", err)
|
|
||||||
}
|
|
||||||
if got, _ := cmd.Flags().GetString("csv"); got != "x,y" {
|
|
||||||
t.Errorf("csv = %q, want %q (explicit value must not be overridden)", got, "x,y")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +1,4 @@
|
|||||||
{
|
{
|
||||||
"+formula-verify": {
|
|
||||||
"risk": "read",
|
|
||||||
"flags": [
|
|
||||||
{
|
|
||||||
"name": "url",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "spreadsheet-token",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sheet-id",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string_slice",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sheet-name",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string_slice",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "range",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "string_slice",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "max-locations",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "int",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": "Max locations / samples per error type; default 20.",
|
|
||||||
"default": "20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "exit-on-error",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "bool",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"+workbook-info": {
|
"+workbook-info": {
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"flags": [
|
"flags": [
|
||||||
@@ -80,32 +25,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"+revision-get": {
|
|
||||||
"risk": "read",
|
|
||||||
"flags": [
|
|
||||||
{
|
|
||||||
"name": "url",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Spreadsheet locator"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "spreadsheet-token",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Spreadsheet locator"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "dry-run",
|
|
||||||
"kind": "system",
|
|
||||||
"type": "bool",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"+sheet-create": {
|
"+sheet-create": {
|
||||||
"risk": "write",
|
"risk": "write",
|
||||||
"flags": [
|
"flags": [
|
||||||
@@ -154,18 +73,6 @@
|
|||||||
"desc": "Initial column count (default 20, max 200)",
|
"desc": "Initial column count (default 20, max 200)",
|
||||||
"default": "20"
|
"default": "20"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "type",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "string",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands",
|
|
||||||
"default": "sheet",
|
|
||||||
"enum": [
|
|
||||||
"sheet",
|
|
||||||
"bitable"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "dry-run",
|
"name": "dry-run",
|
||||||
"kind": "system",
|
"kind": "system",
|
||||||
@@ -312,7 +219,7 @@
|
|||||||
"kind": "own",
|
"kind": "own",
|
||||||
"type": "int",
|
"type": "int",
|
||||||
"required": "optional",
|
"required": "optional",
|
||||||
"desc": "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it",
|
"desc": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`",
|
||||||
"default": "-1"
|
"default": "-1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -608,7 +515,7 @@
|
|||||||
"kind": "own",
|
"kind": "own",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"required": "optional",
|
"required": "optional",
|
||||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||||
"input": [
|
"input": [
|
||||||
"file",
|
"file",
|
||||||
"stdin"
|
"stdin"
|
||||||
@@ -1162,7 +1069,7 @@
|
|||||||
"kind": "own",
|
"kind": "own",
|
||||||
"type": "int",
|
"type": "int",
|
||||||
"required": "optional",
|
"required": "optional",
|
||||||
"desc": "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)",
|
"desc": "Group nesting level to ungroup; default 1 (outermost)",
|
||||||
"default": "1"
|
"default": "1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1804,13 +1711,6 @@
|
|||||||
"required": "optional",
|
"required": "optional",
|
||||||
"desc": "Font color (hex, e.g. `#000000`)"
|
"desc": "Font color (hex, e.g. `#000000`)"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "font-family",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "string",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "font-size",
|
"name": "font-size",
|
||||||
"kind": "own",
|
"kind": "own",
|
||||||
@@ -2394,43 +2294,32 @@
|
|||||||
"required": "xor",
|
"required": "xor",
|
||||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "height",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "int",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`",
|
|
||||||
"default": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "heights",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`",
|
|
||||||
"input": [
|
|
||||||
"file",
|
|
||||||
"stdin"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "type",
|
"name": "type",
|
||||||
"kind": "own",
|
"kind": "own",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"required": "xor",
|
"required": "required",
|
||||||
"desc": "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`",
|
"desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)",
|
||||||
"enum": [
|
"enum": [
|
||||||
"pixel",
|
"pixel",
|
||||||
"standard",
|
"standard",
|
||||||
"auto"
|
"auto"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "size",
|
||||||
|
"kind": "own",
|
||||||
|
"type": "int",
|
||||||
|
"required": "optional",
|
||||||
|
"desc": "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise",
|
||||||
|
"default": "0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "range",
|
"name": "range",
|
||||||
"kind": "own",
|
"kind": "own",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"required": "xor",
|
"required": "required",
|
||||||
"desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)"
|
"desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dry-run",
|
"name": "dry-run",
|
||||||
@@ -2472,42 +2361,31 @@
|
|||||||
"required": "xor",
|
"required": "xor",
|
||||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "width",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "int",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`",
|
|
||||||
"default": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "widths",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`",
|
|
||||||
"input": [
|
|
||||||
"file",
|
|
||||||
"stdin"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "type",
|
"name": "type",
|
||||||
"kind": "own",
|
"kind": "own",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"required": "xor",
|
"required": "required",
|
||||||
"desc": "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`",
|
"desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)",
|
||||||
"enum": [
|
"enum": [
|
||||||
"pixel",
|
"pixel",
|
||||||
"standard"
|
"standard"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "size",
|
||||||
|
"kind": "own",
|
||||||
|
"type": "int",
|
||||||
|
"required": "optional",
|
||||||
|
"desc": "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise",
|
||||||
|
"default": "0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "range",
|
"name": "range",
|
||||||
"kind": "own",
|
"kind": "own",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"required": "xor",
|
"required": "required",
|
||||||
"desc": "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)"
|
"desc": "Column closed range to resize; column letters like `A:E` or `C` (single column)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dry-run",
|
"name": "dry-run",
|
||||||
@@ -2861,7 +2739,7 @@
|
|||||||
"kind": "own",
|
"kind": "own",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"required": "required",
|
"required": "required",
|
||||||
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
|
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
|
||||||
"input": [
|
"input": [
|
||||||
"file",
|
"file",
|
||||||
"stdin"
|
"stdin"
|
||||||
@@ -2881,13 +2759,6 @@
|
|||||||
"required": "optional",
|
"required": "optional",
|
||||||
"desc": "Font color (hex, e.g. `#000000`)"
|
"desc": "Font color (hex, e.g. `#000000`)"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "font-family",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "string",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "font-size",
|
"name": "font-size",
|
||||||
"kind": "own",
|
"kind": "own",
|
||||||
@@ -3014,7 +2885,7 @@
|
|||||||
"kind": "own",
|
"kind": "own",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"required": "required",
|
"required": "required",
|
||||||
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
|
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
|
||||||
"input": [
|
"input": [
|
||||||
"file",
|
"file",
|
||||||
"stdin"
|
"stdin"
|
||||||
@@ -3094,7 +2965,7 @@
|
|||||||
"kind": "own",
|
"kind": "own",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"required": "required",
|
"required": "required",
|
||||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
|
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
|
||||||
"input": [
|
"input": [
|
||||||
"file",
|
"file",
|
||||||
"stdin"
|
"stdin"
|
||||||
@@ -3138,7 +3009,7 @@
|
|||||||
"kind": "own",
|
"kind": "own",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"required": "required",
|
"required": "required",
|
||||||
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
|
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
|
||||||
"input": [
|
"input": [
|
||||||
"file",
|
"file",
|
||||||
"stdin"
|
"stdin"
|
||||||
@@ -3256,7 +3127,7 @@
|
|||||||
"kind": "own",
|
"kind": "own",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"required": "required",
|
"required": "required",
|
||||||
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
|
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
|
||||||
"input": [
|
"input": [
|
||||||
"file",
|
"file",
|
||||||
"stdin"
|
"stdin"
|
||||||
@@ -4195,7 +4066,7 @@
|
|||||||
"kind": "own",
|
"kind": "own",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"required": "optional",
|
"required": "optional",
|
||||||
"desc": "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"
|
"desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dry-run",
|
"name": "dry-run",
|
||||||
@@ -4876,138 +4747,5 @@
|
|||||||
"desc": ""
|
"desc": ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"+history-list": {
|
|
||||||
"risk": "read",
|
|
||||||
"flags": [
|
|
||||||
{
|
|
||||||
"name": "url",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Spreadsheet locator"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "spreadsheet-token",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Spreadsheet locator"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "end-version",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "int",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "dry-run",
|
|
||||||
"kind": "system",
|
|
||||||
"type": "bool",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"+history-revert": {
|
|
||||||
"risk": "write",
|
|
||||||
"flags": [
|
|
||||||
{
|
|
||||||
"name": "url",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Spreadsheet locator"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "spreadsheet-token",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Spreadsheet locator"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "history-version-id",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "string",
|
|
||||||
"required": "required",
|
|
||||||
"desc": "History version to revert to (from +history-list)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "dry-run",
|
|
||||||
"kind": "system",
|
|
||||||
"type": "bool",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"+history-revert-status": {
|
|
||||||
"risk": "read",
|
|
||||||
"flags": [
|
|
||||||
{
|
|
||||||
"name": "url",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Spreadsheet locator"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "spreadsheet-token",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Spreadsheet locator"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "transaction-id",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "string",
|
|
||||||
"required": "required",
|
|
||||||
"desc": "Async revert transaction id (from +history-revert)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "dry-run",
|
|
||||||
"kind": "system",
|
|
||||||
"type": "bool",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"+changeset-get": {
|
|
||||||
"risk": "read",
|
|
||||||
"flags": [
|
|
||||||
{
|
|
||||||
"name": "url",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "spreadsheet-token",
|
|
||||||
"kind": "public",
|
|
||||||
"type": "string",
|
|
||||||
"required": "xor",
|
|
||||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "start-revision",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "int",
|
|
||||||
"required": "required",
|
|
||||||
"desc": "Start version (CS revision); the before baseline for review (must be >= 1)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "end-revision",
|
|
||||||
"kind": "own",
|
|
||||||
"type": "int",
|
|
||||||
"required": "optional",
|
|
||||||
"desc": "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20",
|
|
||||||
"default": "-1"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@ var flagDefs = map[string]commandDef{
|
|||||||
Flags: []flagDef{
|
Flags: []flagDef{
|
||||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
|
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
|
||||||
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
||||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
|
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||||
@@ -38,10 +38,9 @@ var flagDefs = map[string]commandDef{
|
|||||||
Flags: []flagDef{
|
Flags: []flagDef{
|
||||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
|
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
|
||||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||||
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
|
|
||||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||||
@@ -166,7 +165,6 @@ var flagDefs = map[string]commandDef{
|
|||||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
|
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
|
||||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||||
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
|
|
||||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||||
@@ -190,15 +188,6 @@ var flagDefs = map[string]commandDef{
|
|||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"+changeset-get": {
|
|
||||||
Risk: "read",
|
|
||||||
Flags: []flagDef{
|
|
||||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
|
||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
|
||||||
{Name: "start-revision", Kind: "own", Type: "int", Required: "required", Desc: "Start version (CS revision); the before baseline for review (must be >= 1)"},
|
|
||||||
{Name: "end-revision", Kind: "own", Type: "int", Required: "optional", Desc: "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", Default: "-1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"+chart-create": {
|
"+chart-create": {
|
||||||
Risk: "write",
|
Risk: "write",
|
||||||
Flags: []flagDef{
|
Flags: []flagDef{
|
||||||
@@ -206,7 +195,7 @@ var flagDefs = map[string]commandDef{
|
|||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
|
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -252,10 +241,9 @@ var flagDefs = map[string]commandDef{
|
|||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||||
{Name: "width", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`", Default: "0"},
|
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", Enum: []string{"pixel", "standard"}},
|
||||||
{Name: "widths", Kind: "own", Type: "string", Required: "xor", Desc: "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", Input: []string{"file", "stdin"}},
|
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", Default: "0"},
|
||||||
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`", Enum: []string{"pixel", "standard"}},
|
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column)"},
|
||||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)"},
|
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -417,7 +405,7 @@ var flagDefs = map[string]commandDef{
|
|||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"},
|
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
|
||||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||||
},
|
},
|
||||||
@@ -438,7 +426,7 @@ var flagDefs = map[string]commandDef{
|
|||||||
Flags: []flagDef{
|
Flags: []flagDef{
|
||||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||||
},
|
},
|
||||||
@@ -475,7 +463,7 @@ var flagDefs = map[string]commandDef{
|
|||||||
Flags: []flagDef{
|
Flags: []flagDef{
|
||||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||||
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
||||||
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
||||||
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
|
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
|
||||||
@@ -538,7 +526,7 @@ var flagDefs = map[string]commandDef{
|
|||||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
|
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
|
||||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"},
|
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -644,45 +632,6 @@ var flagDefs = map[string]commandDef{
|
|||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"+formula-verify": {
|
|
||||||
Risk: "read",
|
|
||||||
Flags: []flagDef{
|
|
||||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
|
||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
|
||||||
{Name: "sheet-id", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
|
|
||||||
{Name: "sheet-name", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
|
|
||||||
{Name: "range", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."},
|
|
||||||
{Name: "max-locations", Kind: "own", Type: "int", Required: "optional", Desc: "Max locations / samples per error type; default 20.", Default: "20"},
|
|
||||||
{Name: "exit-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"+history-list": {
|
|
||||||
Risk: "read",
|
|
||||||
Flags: []flagDef{
|
|
||||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
|
||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
|
||||||
{Name: "end-version", Kind: "own", Type: "int", Required: "optional", Desc: "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."},
|
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"+history-revert": {
|
|
||||||
Risk: "write",
|
|
||||||
Flags: []flagDef{
|
|
||||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
|
||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
|
||||||
{Name: "history-version-id", Kind: "own", Type: "string", Required: "required", Desc: "History version to revert to (from +history-list)."},
|
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"+history-revert-status": {
|
|
||||||
Risk: "read",
|
|
||||||
Flags: []flagDef{
|
|
||||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
|
||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
|
||||||
{Name: "transaction-id", Kind: "own", Type: "string", Required: "required", Desc: "Async revert transaction id (from +history-revert)."},
|
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"+pivot-create": {
|
"+pivot-create": {
|
||||||
Risk: "write",
|
Risk: "write",
|
||||||
Flags: []flagDef{
|
Flags: []flagDef{
|
||||||
@@ -785,14 +734,6 @@ var flagDefs = map[string]commandDef{
|
|||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"+revision-get": {
|
|
||||||
Risk: "read",
|
|
||||||
Flags: []flagDef{
|
|
||||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
|
||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"+rows-resize": {
|
"+rows-resize": {
|
||||||
Risk: "write",
|
Risk: "write",
|
||||||
Flags: []flagDef{
|
Flags: []flagDef{
|
||||||
@@ -800,10 +741,9 @@ var flagDefs = map[string]commandDef{
|
|||||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||||
{Name: "height", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`", Default: "0"},
|
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", Enum: []string{"pixel", "standard", "auto"}},
|
||||||
{Name: "heights", Kind: "own", Type: "string", Required: "xor", Desc: "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", Input: []string{"file", "stdin"}},
|
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", Default: "0"},
|
||||||
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`", Enum: []string{"pixel", "standard", "auto"}},
|
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"},
|
||||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)"},
|
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -828,7 +768,6 @@ var flagDefs = map[string]commandDef{
|
|||||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
|
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
|
||||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||||
{Name: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands", Default: "sheet", Enum: []string{"sheet", "bitable"}},
|
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -883,7 +822,7 @@ var flagDefs = map[string]commandDef{
|
|||||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||||
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
|
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
|
||||||
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", Default: "-1"},
|
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1002,7 +941,7 @@ var flagDefs = map[string]commandDef{
|
|||||||
Flags: []flagDef{
|
Flags: []flagDef{
|
||||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
||||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
|
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
|
||||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package sheets
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/suggest"
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/spf13/pflag"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ─── sheets flag ergonomics ─────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Eval traces show two recovery loops that burn agent round-trips on the
|
|
||||||
// sheets domain specifically: hallucinated flag names (--cols for --range,
|
|
||||||
// --file for --csv) whose unknown-flag error only points at --help, and
|
|
||||||
// enum values imported from CSS / Excel vocabulary ("center" for the
|
|
||||||
// vertical alignment Lark spells "middle"). Both fixes are wired through
|
|
||||||
// the existing PostMount hook — composed onto any prior PostMount in
|
|
||||||
// Shortcuts(), same pattern as withTokenAlias — so the common framework
|
|
||||||
// needs no change at all and no other domain's behavior shifts.
|
|
||||||
|
|
||||||
// withFlagErgonomics wraps an optional PostMount so that, after it runs,
|
|
||||||
// the command gets the sheets-specific unknown-flag error (valid flags
|
|
||||||
// inlined) and enum-value normalization (canonical vocabulary auto-applied,
|
|
||||||
// typos suggested).
|
|
||||||
func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
|
||||||
return func(cmd *cobra.Command) {
|
|
||||||
if prev != nil {
|
|
||||||
prev(cmd)
|
|
||||||
}
|
|
||||||
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
|
|
||||||
chainEnumNormalization(cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
|
|
||||||
// It keeps the root behavior (typed error, did-you-mean suggestions, the
|
|
||||||
// offending flag on params) and additionally inlines the full valid-flag
|
|
||||||
// set: hallucinated sheets flags are usually semantic guesses (--cols for
|
|
||||||
// --range) that edit distance can't rank, and a --help round trip costs an
|
|
||||||
// agent a full extra call. One line here lets it re-issue the command
|
|
||||||
// immediately.
|
|
||||||
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
|
||||||
name, isUnknown := unknownFlagFromParseError(ferr)
|
|
||||||
if !isUnknown {
|
|
||||||
return common.ValidationErrorf("%s", ferr.Error()).
|
|
||||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
|
||||||
}
|
|
||||||
valid := visibleFlagNames(c)
|
|
||||||
suggestions := suggest.Closest(name, valid, 3)
|
|
||||||
for i := range suggestions {
|
|
||||||
suggestions[i] = "--" + suggestions[i]
|
|
||||||
}
|
|
||||||
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
|
|
||||||
if list := inlineFlagList(valid); list != "" {
|
|
||||||
hint = "valid flags: " + list
|
|
||||||
if len(suggestions) > 0 {
|
|
||||||
hint = fmt.Sprintf("did you mean %s? valid flags: %s",
|
|
||||||
strings.Join(suggestions, ", "), list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
|
||||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
|
||||||
WithHint("%s", hint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// unknownFlagFromParseError extracts the offending long-flag name from
|
|
||||||
// cobra's flag-parse error text ("unknown flag: --query" → "query").
|
|
||||||
// Returns ok=false for anything else (missing argument, invalid value,
|
|
||||||
// unknown shorthand) so those stay structured but generic. Mirrors the
|
|
||||||
// root-level parser in cmd; the prefix contract is cobra's English wording.
|
|
||||||
func unknownFlagFromParseError(err error) (string, bool) {
|
|
||||||
const p = "unknown flag: --"
|
|
||||||
msg := err.Error()
|
|
||||||
i := strings.Index(msg, p)
|
|
||||||
if i < 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
rest := msg[i+len(p):]
|
|
||||||
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
|
||||||
rest = rest[:j]
|
|
||||||
}
|
|
||||||
return rest, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// visibleFlagNames lists the non-hidden flag names registered on c, sorted.
|
|
||||||
func visibleFlagNames(c *cobra.Command) []string {
|
|
||||||
var names []string
|
|
||||||
c.Flags().VisitAll(func(f *pflag.Flag) {
|
|
||||||
if !f.Hidden {
|
|
||||||
names = append(names, f.Name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
// inlineFlagListLimit caps how many flag names ride inline on an
|
|
||||||
// unknown-flag hint. Sheets shortcuts stay well under it.
|
|
||||||
const inlineFlagListLimit = 25
|
|
||||||
|
|
||||||
// inlineFlagList renders valid flag names as one comma-separated line for
|
|
||||||
// the unknown-flag hint, truncating past inlineFlagListLimit. Empty when
|
|
||||||
// there is nothing to list.
|
|
||||||
func inlineFlagList(names []string) string {
|
|
||||||
if len(names) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
shown := names
|
|
||||||
var suffix string
|
|
||||||
if len(names) > inlineFlagListLimit {
|
|
||||||
shown = names[:inlineFlagListLimit]
|
|
||||||
suffix = fmt.Sprintf(", … (%d more; see --help)", len(names)-inlineFlagListLimit)
|
|
||||||
}
|
|
||||||
parts := make([]string, len(shown))
|
|
||||||
for i, n := range shown {
|
|
||||||
parts[i] = "--" + n
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ", ") + suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── enum vocabulary normalization ──────────────────────────────────────
|
|
||||||
|
|
||||||
// enumAliases maps habitual values agents import from CSS / Excel / Google
|
|
||||||
// Sheets onto the value the Lark API actually uses, keyed by the wrong
|
|
||||||
// value. Applied only when the alias target is in the enum (and the wrong
|
|
||||||
// value is not), so e.g. "center" still stands for horizontal alignment
|
|
||||||
// (where it is valid) and only maps to "middle" for vertical alignment.
|
|
||||||
var enumAliases = map[string]string{
|
|
||||||
"center": "middle", // CSS vertical-align: center → Lark "middle"
|
|
||||||
"centre": "center",
|
|
||||||
"middle": "center", // CSS-style middle → Lark horizontal "center"
|
|
||||||
}
|
|
||||||
|
|
||||||
// canonicalEnumValue returns the enum entry an off-vocabulary value
|
|
||||||
// unambiguously means — exact case-insensitive match first, then the
|
|
||||||
// cross-vocabulary alias table. Unlike an edit-distance guess, the result
|
|
||||||
// is safe to apply on the caller's behalf. Returns "" when the value has
|
|
||||||
// no unambiguous canonical form in this enum.
|
|
||||||
func canonicalEnumValue(val string, enum []string) string {
|
|
||||||
lower := strings.ToLower(val)
|
|
||||||
for _, allowed := range enum {
|
|
||||||
if strings.ToLower(allowed) == lower {
|
|
||||||
return allowed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if target, ok := enumAliases[lower]; ok {
|
|
||||||
if slices.Contains(enum, target) {
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// closestEnumValue picks the best "did you mean" candidate for an invalid
|
|
||||||
// enum value: the unambiguous canonical form first, then edit distance.
|
|
||||||
// For prose suggestions only — an edit-distance match must never be
|
|
||||||
// auto-applied. Returns "" when nothing is close.
|
|
||||||
func closestEnumValue(val string, enum []string) string {
|
|
||||||
if canon := canonicalEnumValue(val, enum); canon != "" {
|
|
||||||
return canon
|
|
||||||
}
|
|
||||||
if match := suggest.Closest(val, enum, 1); len(match) > 0 {
|
|
||||||
return match[0]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// chainEnumNormalization installs a PreRunE stage (composed onto any
|
|
||||||
// framework-set PreRunE, which runs first so OnInvoke side effects and the
|
|
||||||
// --print-schema required-flag relaxation keep their contracts) that
|
|
||||||
// normalizes the command's flat enum flags before the common runner
|
|
||||||
// validates them:
|
|
||||||
//
|
|
||||||
// - an unambiguous vocabulary mismatch (casing, or a known alias like CSS
|
|
||||||
// "center" for Lark's vertical "middle") IS the value the caller meant —
|
|
||||||
// rewrite it in place and proceed instead of failing the call just to
|
|
||||||
// have the agent retype the canonical spelling;
|
|
||||||
// - anything else fails here with the allowed list plus a "did you mean"
|
|
||||||
// hint for edit-distance typos — a guess is never auto-applied.
|
|
||||||
//
|
|
||||||
// No-op for commands whose flag defs declare no enums.
|
|
||||||
func chainEnumNormalization(cmd *cobra.Command) {
|
|
||||||
defs, _ := loadFlagDefs()
|
|
||||||
spec, ok := defs[cmd.Name()]
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var enumFlags []flagDef
|
|
||||||
for _, df := range spec.Flags {
|
|
||||||
if df.Kind != "system" && len(df.Enum) > 0 && df.Type == "string" {
|
|
||||||
enumFlags = append(enumFlags, df)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(enumFlags) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prev := cmd.PreRunE
|
|
||||||
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
|
||||||
if prev != nil {
|
|
||||||
if err := prev(c, args); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// --print-schema is pure local introspection; the runner never enum-
|
|
||||||
// validates that path, so don't start here.
|
|
||||||
if want, err := c.Flags().GetBool("print-schema"); err == nil && want {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, df := range enumFlags {
|
|
||||||
val, err := c.Flags().GetString(df.Name)
|
|
||||||
if err != nil || val == "" || slices.Contains(df.Enum, val) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if canon := canonicalEnumValue(val, df.Enum); canon != "" {
|
|
||||||
c.Flags().Set(df.Name, canon)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
verr := common.ValidationErrorf("invalid value %q for --%s, allowed: %s",
|
|
||||||
val, df.Name, strings.Join(df.Enum, ", ")).
|
|
||||||
WithParam("--" + df.Name)
|
|
||||||
if match := suggest.Closest(val, df.Enum, 1); len(match) > 0 {
|
|
||||||
verr = verr.WithHint("did you mean %q?", match[0])
|
|
||||||
}
|
|
||||||
return verr
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user