diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 02e2274d1..bf5932413 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,7 +9,40 @@ permissions:
contents: read
jobs:
- goreleaser:
+ preflight:
+ runs-on: ubuntu-22.04
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: '22.14.0'
+
+ - name: Validate tag and commit
+ env:
+ TAG: ${{ github.ref_name }}
+ run: |
+ set -euo pipefail
+ node scripts/release-preflight.js --tag "$TAG"
+ git fetch origin main
+ HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
+ MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
+ TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
+ if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
+ echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
+ exit 1
+ fi
+ if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
+ echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
+ exit 1
+ fi
+
+ build-release:
+ needs: preflight
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -26,35 +59,79 @@ jobs:
with:
python-version: '3.x'
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: '22.14.0'
+ registry-url: 'https://registry.npmjs.org'
+ package-manager-cache: false
+
+ - name: Install pinned npm
+ run: npm install --global npm@11.16.0
+
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_TOKEN: ${{ github.token }}
+
+ - name: Include release checksums
+ run: |
+ set -euo pipefail
+ test -s dist/checksums.txt
+ (cd dist && sha256sum --check checksums.txt)
+ cp dist/checksums.txt checksums.txt
+
+ - name: Collect release asset
+ run: |
+ set -euo pipefail
+ mkdir npm-publish-asset
+ cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
+
+ - name: Upload release asset
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: npm-publish-asset-${{ github.run_id }}
+ path: npm-publish-asset/
+ if-no-files-found: error
+ overwrite: true
publish-npm:
- needs: goreleaser
+ needs: build-release
runs-on: ubuntu-22.04
+ environment: npm-production
+ permissions:
+ contents: read
+ id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
- node-version: '20'
+ node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
+ package-manager-cache: false
- - name: Download checksums from release
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Install pinned npm
+ run: npm install --global npm@11.16.0
+
+ - name: Download release asset
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: npm-publish-asset-${{ github.run_id }}
+ path: npm-publish-asset
+
+ - name: Verify npm publish asset
run: |
set -euo pipefail
- TAG="${GITHUB_REF_NAME}"
- gh release download "${TAG}" --pattern checksums.txt --dir .
- test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
+ (cd npm-publish-asset && sha256sum --check checksums.txt)
+ cp npm-publish-asset/checksums.txt checksums.txt
+ PACK_JSON="$(npm pack --ignore-scripts --json)"
+ PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
+ test -s "$PACK_FILE"
+ tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
+ rm "$PACK_FILE"
- name: Publish to npm
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public
diff --git a/.github/workflows/semantic-review.yml b/.github/workflows/semantic-review.yml
index 2fcf298cd..c18ecdb78 100644
--- a/.github/workflows/semantic-review.yml
+++ b/.github/workflows/semantic-review.yml
@@ -25,19 +25,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
- if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
- let workflowPath = run.path || "";
- if (!workflowPath) {
- const workflowId = Number(run.workflow_id || 0);
- if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
- const { data: workflow } = await github.rest.actions.getWorkflow({
- owner: context.repo.owner,
- repo: context.repo.repo,
- workflow_id: workflowId,
- });
- workflowPath = workflow.path || "";
- }
- if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
+ const workflowId = Number(run.workflow_id || 0);
+ if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
+ const { data: workflow } = await github.rest.actions.getWorkflow({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ workflow_id: workflowId,
+ });
+ if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
+ if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
+ if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
@@ -253,19 +250,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
- if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
- let workflowPath = run.path || "";
- if (!workflowPath) {
- const workflowId = Number(run.workflow_id || 0);
- if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
- const { data: workflow } = await github.rest.actions.getWorkflow({
- owner: context.repo.owner,
- repo: context.repo.repo,
- workflow_id: workflowId,
- });
- workflowPath = workflow.path || "";
- }
- if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
+ const workflowId = Number(run.workflow_id || 0);
+ if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
+ const { data: workflow } = await github.rest.actions.getWorkflow({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ workflow_id: workflowId,
+ });
+ if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
+ if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
+ if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
diff --git a/AGENTS.md b/AGENTS.md
index 87c6892b3..0bfcd2093 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,9 +10,10 @@
## Build & Test
```bash
-make build # Build (runs fetch_meta first)
-make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
-make test # Full: vet + unit + integration
+make build # Build (runs fetch_meta first)
+make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
+make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
+make test # Full: vet + unit + integration
```
## Notification Opt-Outs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 455655e21..27d50366b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,155 @@
All notable changes to this project will be documented in this file.
+## [v1.0.79] - 2026-07-28
+
+### Features
+
+- **slides**: update xsd (#2067)
+
+### Bug Fixes
+
+- **ci**: validate static workflow identity (#2015)
+- **sheets**: recognize OFL0X local office tokens (#2063)
+
+### Documentation
+
+- **calendar**: clarify identity selection by event ownership (#2071)
+- **slides**: add formula inline element syntax to quick-ref (#2077)
+
+## [v1.0.78] - 2026-07-27
+
+### Features
+
+- event description support rich text (#1975)
+
+### Bug Fixes
+
+- **slides**: restrict canvas overflow checks
+- **slides**: upgrade text overflow to error above 10px threshold
+- **slides**: detect letterSpacing-driven text overflow
+- **slides**: downgrade background-decoration text overflow to info
+- **slides**: allow chartParsedValues roundtrip tag
+- refine character width estimation for lark-slides text lint
+- **slides**: preserve info lint severity
+- **slides**: text may over flow shape
+- exempt ghost text from slides lint
+
+## [v1.0.77] - 2026-07-24
+
+### Features
+
+- introducing official card icon (#1973)
+- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
+- **apps**: support absolute and relative upload paths (#2005)
+- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
+- **slides**: add layout density lint for sparse/empty containers (#2022)
+- add risk-control protection (#1910)
+
+### Bug Fixes
+
+- **slides**: normalize presentation flag aliases (#2032)
+- **base**: classify +form-submit as high-risk-write (#1969)
+- **slides**: declare screenshot scope
+- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
+
+### Documentation
+
+- **skill**: clarify scope handling for query expansion (#2030)
+- **base**: clarify complete and partial updates (#1993)
+- **skills**: clarify callout child rules (#2048)
+
+### Misc
+
+- fix/task id handling (#2023)
+- fix/task search pagination (#2041)
+
+## [v1.0.75] - 2026-07-22
+
+### Features
+
+- add okr single create shortcut & skill text opti (#1941)
+- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
+
+### Bug Fixes
+
+- **base**: improve table shortcut behavior & guidance (#1803)
+- issue#1935 & whiteboard shortcut reformat (#1980)
+- remove legacy shortcut (#1997)
+- **e2e**: inject shared credentials by identity (#1995)
+
+### Documentation
+
+- **skill**: describe html5 block xml usage (#1380)
+- clarify fetch metadata and user cites (#1981)
+- add topic move collector workflow (#1473)
+- update lark doc HTML size limit (#2001)
+- **base**: align record write schema guidance (#2000)
+
+### Tests
+
+- **e2e**: declare request identities explicitly (#2004)
+
+### Misc
+
+- harden npm release publishing (#1918)
+
+## [v1.0.74] - 2026-07-21
+
+### Features
+
+- **slides**: add history rollback shortcuts (#1714)
+- **base**: support per-record batch updates (#1889)
+
+### Bug Fixes
+
+- preserve slides schema issues
+- allow jq examples in quality gate dry-runs
+- **im**: warn when flag pagination is truncated (#1906)
+- **slides**: warn on text shape overflow
+- **slides**: exempt chart roundtrip attributes from lint
+- **slides**: detect image text occlusion
+- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
+
+### Documentation
+
+- clarify drive upload overwrite guidance (#1982)
+
+### Tests
+
+- isolate unit tests from user state (#1883)
+
+### Refactoring
+
+- converge success output through a single Emitter that owns the write (#1899)
+
+## [v1.0.73] - 2026-07-20
+
+### Features
+
+- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
+
+### Bug Fixes
+
+- **slides**: detect visual elements outside canvas
+- reduce public content credential fixture false positives
+- standardize CLI shortcut text in English (#1942)
+
+### Documentation
+
+- **base**: reduce filter and update retry loops (#1879)
+- **vc**: default transcript routing to smart notes over minutes (#1961)
+- clarify local trigger automation (#1958)
+
+### Tests
+
+- synchronize temporary Git maintenance (#1946)
+
+### Misc
+
+- **slides**: update lark-slides skill to 0715 snapshot (#1933)
+- [codex] support bot menu events (#1765)
+
## [v1.0.72] - 2026-07-17
### Features
@@ -1552,6 +1701,12 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
+[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
+[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
+[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
+[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
+[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
+[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
diff --git a/Makefile b/Makefile
index 694734b97..a338519e3 100644
--- a/Makefile
+++ b/Makefile
@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
-.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
+.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
all: test
@@ -51,13 +51,18 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
- $(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
+ $(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/...
+live-skills-test: fetch_meta
+ LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \
+ go test -v -count=1 ./cmd/update \
+ -run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$'
+
# examples-build keeps the shipped plugin-SDK examples compilable. If this
# breaks, the plugin author guide's "go build ./..." path is broken.
examples-build:
diff --git a/README.md b/README.md
index 0d6fc24c2..b46343488 100644
--- a/README.md
+++ b/README.md
@@ -285,6 +285,29 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
+To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
+
+- Operating system type: macOS, Windows, or Linux
+- Device hardware model: for example, Mac17,9
+
+To disable this protection for the current workspace, run:
+
+```bash
+lark-cli config risk-control off
+```
+
+To enable this protection for the current workspace, run:
+
+```bash
+lark-cli config risk-control on
+```
+
+To restore the default policy for the current workspace, run:
+
+```bash
+lark-cli config risk-control default
+```
+
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History
diff --git a/README.zh.md b/README.zh.md
index 74b706405..4501b303d 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -286,6 +286,29 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
+为降低访问令牌被盗用后的安全风险,CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
+
+- 操作系统类型:macOS、Windows 或 Linux
+- 设备的硬件产品型号:例如 Mac17,9
+
+如需让当前 workspace 退出该保护,可执行以下命令:
+
+```bash
+lark-cli config risk-control off
+```
+
+如需开启当前 workspace 的保护,可执行以下命令:
+
+```bash
+lark-cli config risk-control on
+```
+
+恢复当前 workspace 默认策略可执行:
+
+```bash
+lark-cli config risk-control default
+```
+
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History
diff --git a/cmd/api/api.go b/cmd/api/api.go
index 368fd6cfd..4d1a039f1 100644
--- a/cmd/api/api.go
+++ b/cmd/api/api.go
@@ -344,20 +344,18 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
- pf := output.NewPaginatedFormatter(out, format)
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: out,
+ ErrOut: errOut,
+ CommandPath: commandPath,
+ Identity: string(pagOpts.Identity),
+ NoticeProvider: output.GetNotice,
+ })
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
- scanResult := output.ScanForSafety(commandPath, items, errOut)
- if scanResult.Blocked {
- return scanResult.BlockErr
- }
- if scanResult.Alert != nil {
- output.WriteAlertWarning(errOut, scanResult.Alert)
- }
- pf.FormatPage(items)
- return nil
+ return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)
diff --git a/cmd/api/api_paginate_test.go b/cmd/api/api_paginate_test.go
new file mode 100644
index 000000000..11e576bfe
--- /dev/null
+++ b/cmd/api/api_paginate_test.go
@@ -0,0 +1,396 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/client"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/internal/output"
+)
+
+type apiFailOnWriteWriter struct {
+ buf bytes.Buffer
+ writes int
+ failAt int
+ err error
+}
+
+func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
+ w.writes++
+ if w.writes == w.failAt {
+ return 0, w.err
+ }
+ return w.buf.Write(p)
+}
+
+func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
+ t.Helper()
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ previousNotice := output.PendingNotice
+ output.PendingNotice = nil
+ t.Cleanup(func() { output.PendingNotice = previousNotice })
+
+ config := &core.CliConfig{
+ AppID: "test-app",
+ AppSecret: "test-secret",
+ Brand: core.BrandFeishu,
+ }
+ f, out, errOut, reg := cmdutil.TestFactory(t, config)
+ ac, err := f.NewAPIClientWithConfig(config)
+ if err != nil {
+ t.Fatalf("NewAPIClientWithConfig() error = %v", err)
+ }
+ ac.ErrOut = io.Discard
+ return ac, out, errOut, reg
+}
+
+func apiPaginateRequest() client.RawApiRequest {
+ return client.RawApiRequest{
+ Method: "GET",
+ URL: "/open-apis/test/v1/items",
+ As: core.AsBot,
+ }
+}
+
+func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
+ t.Helper()
+ wantBytes, err := json.MarshalIndent(want, "", " ")
+ if err != nil {
+ t.Fatalf("marshal expected JSON: %v", err)
+ }
+ wantBytes = append(wantBytes, '\n')
+ if !bytes.Equal(got, wantBytes) {
+ t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
+ }
+}
+
+func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
+ ac, out, errOut, reg := newAPIPaginateTestHarness(t)
+ calls := 0
+ wantTokens := []string{"", "next-1", "next-2"}
+ for i, wantToken := range wantTokens {
+ page := i + 1
+ hasMore := page < len(wantTokens)
+ data := map[string]interface{}{
+ "items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
+ "has_more": hasMore,
+ }
+ if hasMore {
+ data["page_token"] = wantTokens[page]
+ }
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ OnMatch: func(req *http.Request) {
+ calls++
+ if got := req.URL.Query().Get("page_token"); got != wantToken {
+ t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
+ }
+ },
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": data,
+ },
+ })
+ }
+
+ err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
+ output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
+ PageLimit: 10,
+ PageDelay: -1,
+ })
+
+ if err != nil {
+ t.Fatalf("apiPaginate() error = %v, want nil", err)
+ }
+ if calls != 3 {
+ t.Fatalf("pagination requests = %d, want 3", calls)
+ }
+ assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
+ OK: true,
+ Identity: "bot",
+ Data: map[string]interface{}{
+ "items": []interface{}{
+ map[string]interface{}{"id": "1"},
+ map[string]interface{}{"id": "2"},
+ map[string]interface{}{"id": "3"},
+ },
+ "has_more": false,
+ },
+ })
+ if got := errOut.String(); got != "" {
+ t.Fatalf("stderr bytes = %q, want empty", got)
+ }
+}
+
+func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
+ tests := []struct {
+ name string
+ format output.Format
+ want string
+ }{
+ {
+ name: "ndjson",
+ format: output.FormatNDJSON,
+ want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
+ },
+ {
+ name: "table",
+ format: output.FormatTable,
+ want: "id name \n── ─────\n1 Alice\n2 Carol\n",
+ },
+ {
+ name: "csv",
+ format: output.FormatCSV,
+ want: "id,name\n1,Alice\n2,Carol\n",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ac, out, errOut, reg := newAPIPaginateTestHarness(t)
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "items": []interface{}{
+ map[string]interface{}{"id": "1", "name": "Alice"},
+ },
+ "has_more": true,
+ "page_token": "next-1",
+ },
+ },
+ })
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "items": []interface{}{
+ map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
+ },
+ "has_more": false,
+ },
+ },
+ })
+
+ err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
+ tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
+ PageLimit: 10,
+ PageDelay: -1,
+ })
+
+ if err != nil {
+ t.Fatalf("apiPaginate() error = %v, want nil", err)
+ }
+ if got := out.String(); got != tt.want {
+ t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
+ }
+ if got := errOut.String(); got != "" {
+ t.Fatalf("stderr bytes = %q, want empty", got)
+ }
+ })
+ }
+}
+
+func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
+ ac, _, errOut, reg := newAPIPaginateTestHarness(t)
+ sentinel := errors.New("page write failed")
+ out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
+ calls := 0
+ for page := 1; page <= 2; page++ {
+ hasMore := true
+ data := map[string]interface{}{
+ "items": []interface{}{map[string]interface{}{"id": page}},
+ "has_more": hasMore,
+ }
+ if hasMore {
+ data["page_token"] = fmt.Sprintf("next-%d", page)
+ }
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ OnMatch: func(*http.Request) {
+ calls++
+ },
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": data,
+ },
+ })
+ }
+
+ err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
+ output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
+ client.PaginationOptions{PageLimit: 10, PageDelay: -1})
+
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryInternal {
+ t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
+ }
+ if calls != 2 {
+ t.Fatalf("pagination requests = %d, want 2", calls)
+ }
+ if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
+ t.Fatalf("stdout bytes = %q, want %q", got, want)
+ }
+}
+
+func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
+ ac, out, errOut, reg := newAPIPaginateTestHarness(t)
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "name": "Test User",
+ "user_id": "u123",
+ },
+ },
+ })
+
+ err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
+ output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
+
+ if err != nil {
+ t.Fatalf("apiPaginate() error = %v, want nil", err)
+ }
+ assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
+ OK: true,
+ Identity: "bot",
+ Data: map[string]interface{}{
+ "name": "Test User",
+ "user_id": "u123",
+ },
+ })
+ wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
+ if got := errOut.String(); got != wantWarning {
+ t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
+ }
+}
+
+func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
+ businessResponse := map[string]interface{}{
+ "code": 123456,
+ "msg": "fixture business error",
+ "data": map[string]interface{}{"detail": "business failed"},
+ }
+ tests := []struct {
+ name string
+ format output.Format
+ jqExpr string
+ }{
+ {name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
+ {name: "default_json", format: output.FormatJSON},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ac, out, errOut, reg := newAPIPaginateTestHarness(t)
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ Body: businessResponse,
+ })
+
+ err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
+ tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
+
+ if err == nil {
+ t.Fatal("apiPaginate() error = nil, want business error")
+ }
+ if !errs.IsRaw(err) {
+ t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
+ }
+ assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
+ if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
+ t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
+ }
+ if got := errOut.String(); got != "" {
+ t.Fatalf("stderr bytes = %q, want empty", got)
+ }
+ })
+ }
+}
+
+func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
+ tests := []struct {
+ name string
+ format output.Format
+ jqExpr string
+ }{
+ {name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
+ {name: "stream_pages", format: output.FormatNDJSON},
+ {name: "default_paginate_all", format: output.FormatJSON},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ac, out, errOut, _ := newAPIPaginateTestHarness(t)
+
+ err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
+ tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
+
+ if err == nil {
+ t.Fatal("apiPaginate() error = nil, want transport error")
+ }
+ if !errs.IsRaw(err) {
+ t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
+ }
+ if got := out.String(); got != "" {
+ t.Fatalf("stdout bytes = %q, want empty", got)
+ }
+ if got := errOut.String(); got != "" {
+ t.Fatalf("stderr bytes = %q, want empty", got)
+ }
+ })
+ }
+}
+
+func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
+ ac, out, errOut, reg := newAPIPaginateTestHarness(t)
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ Body: map[string]interface{}{
+ "code": 123456,
+ "msg": "fixture business error",
+ "data": map[string]interface{}{},
+ },
+ })
+
+ err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
+ output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
+
+ if err == nil {
+ t.Fatal("apiPaginate() error = nil, want business error")
+ }
+ if !errs.IsRaw(err) {
+ t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
+ }
+ if got := out.String(); got != "" {
+ t.Fatalf("stdout bytes = %q, want empty", got)
+ }
+ if got := errOut.String(); got != "" {
+ t.Fatalf("stderr bytes = %q, want empty", got)
+ }
+}
diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go
index ae0628f95..c269bb9bb 100644
--- a/cmd/api/api_test.go
+++ b/cmd/api/api_test.go
@@ -352,6 +352,9 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
}
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
})
@@ -371,8 +374,33 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
if !strings.Contains(stderr.String(), "binary response detected") {
t.Error("expected binary response hint in stderr")
}
- if !strings.Contains(stdout.String(), "saved_path") {
- t.Error("expected saved_path in output")
+ var got map[string]interface{}
+ if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
+ t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String())
+ }
+ savedPath, _ := got["saved_path"].(string)
+ if savedPath == "" {
+ t.Fatalf("saved_path missing from output: %#v", got)
+ }
+ // The file must land inside the temporary cwd — this pins the isolation
+ // contract: rolling back TestChdir would leave download.bin in the repo.
+ wantDir, err := filepath.EvalSymlinks(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath))
+ if err != nil {
+ t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err)
+ }
+ if gotDir != wantDir {
+ t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir)
+ }
+ content, err := os.ReadFile(savedPath)
+ if err != nil {
+ t.Fatalf("read saved file: %v", err)
+ }
+ if string(content) != "fake-binary-content" {
+ t.Errorf("saved file content = %q, want %q", content, "fake-binary-content")
}
}
diff --git a/cmd/auth/testmain_test.go b/cmd/auth/testmain_test.go
new file mode 100644
index 000000000..816e31178
--- /dev/null
+++ b/cmd/auth/testmain_test.go
@@ -0,0 +1,46 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/larksuite/cli/internal/registry/registrytest"
+)
+
+// TestMain isolates auth command tests from the host machine: config, logs
+// and the registry cache are redirected to a temp dir, then the registry is
+// seeded from the tracked fixture and initialized eagerly. Domain-completion
+// tests read the registry, so without seeding a clean checkout would either
+// fail or trigger a remote metadata fetch.
+//
+// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
+// m.Run before exiting.
+func TestMain(m *testing.M) {
+ root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*")
+ if err != nil {
+ println("cmd/auth test setup: MkdirTemp failed:", err.Error())
+ os.Exit(2)
+ }
+ if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
+ println("cmd/auth test setup: Setenv failed:", err.Error())
+ os.RemoveAll(root)
+ os.Exit(2)
+ }
+ if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
+ println("cmd/auth test setup: Setenv failed:", err.Error())
+ os.RemoveAll(root)
+ os.Exit(2)
+ }
+ if err := registrytest.Seed(root); err != nil {
+ println("cmd/auth test setup: registrytest.Seed failed:", err.Error())
+ os.RemoveAll(root)
+ os.Exit(2)
+ }
+ code := m.Run()
+ _ = os.RemoveAll(root)
+ os.Exit(code)
+}
diff --git a/cmd/config/config.go b/cmd/config/config.go
index f3c643fd5..ae316af81 100644
--- a/cmd/config/config.go
+++ b/cmd/config/config.go
@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
+ cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
diff --git a/cmd/config/risk_control.go b/cmd/config/risk_control.go
new file mode 100644
index 000000000..f594fcc9c
--- /dev/null
+++ b/cmd/config/risk_control.go
@@ -0,0 +1,80 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package config
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/core"
+)
+
+// NewCmdConfigRiskControl creates the workspace risk-control policy command.
+func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "risk-control [on|off|default]",
+ Short: "Manage workspace account-protection policy",
+ Long: `View or set the account-protection risk-control policy for this workspace.
+
+Account protection is on by default. Use off to opt this workspace out, on to
+opt it back in explicitly, or default to remove the explicit preference.`,
+ Args: cobra.MaximumNArgs(1),
+ // This is persistent workspace policy, not credential management.
+ PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
+ cmd.SilenceUsage = true
+ return nil
+ },
+ RunE: func(cmd *cobra.Command, args []string) error {
+ config, err := core.LoadOrNotConfigured()
+ if err != nil {
+ return err
+ }
+ if len(args) == 0 {
+ printRiskControl(f, config)
+ return nil
+ }
+
+ switch args[0] {
+ case "on":
+ enabled := true
+ config.RiskControl = &enabled
+ case "off":
+ enabled := false
+ config.RiskControl = &enabled
+ case "default":
+ config.RiskControl = nil
+ default:
+ return errs.NewValidationError(errs.SubtypeInvalidArgument,
+ "invalid risk-control value %q, valid values: on | off | default", args[0])
+ }
+
+ if err := core.SaveMultiAppConfig(config); err != nil {
+ return errs.NewInternalError(errs.SubtypeStorage,
+ "failed to save risk-control policy: %v", err).WithCause(err)
+ }
+ fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
+ return nil
+ },
+ }
+ cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
+ return cmd
+}
+
+func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
+ source := "default"
+ if config.RiskControl != nil {
+ source = "workspace"
+ }
+ fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
+}
+
+func riskControlState(enabled bool) string {
+ if enabled {
+ return "on"
+ }
+ return "off"
+}
diff --git a/cmd/config/risk_control_test.go b/cmd/config/risk_control_test.go
new file mode 100644
index 000000000..7b29f830f
--- /dev/null
+++ b/cmd/config/risk_control_test.go
@@ -0,0 +1,130 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package config
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/core"
+)
+
+func TestRiskControlWorkspacePolicy(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ config := &core.MultiAppConfig{Apps: []core.AppConfig{{
+ AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
+ }}}
+ if err := core.SaveMultiAppConfig(config); err != nil {
+ t.Fatal(err)
+ }
+
+ f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
+ cmd := NewCmdConfigRiskControl(f)
+ cmd.SetArgs([]string{"off"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("set off: %v", err)
+ }
+ loaded, err := core.LoadMultiAppConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.RiskControl == nil || *loaded.RiskControl {
+ t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
+ }
+ if !strings.Contains(stderr.String(), "set to off") {
+ t.Fatalf("stderr = %q", stderr.String())
+ }
+
+ stdout.Reset()
+ cmd = NewCmdConfigRiskControl(f)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("show: %v", err)
+ }
+ if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
+ t.Fatalf("stdout = %q", got)
+ }
+
+ cmd = NewCmdConfigRiskControl(f)
+ cmd.SetArgs([]string{"on"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("set on: %v", err)
+ }
+ loaded, err = core.LoadMultiAppConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.RiskControl == nil || !*loaded.RiskControl {
+ t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
+ }
+
+ cmd = NewCmdConfigRiskControl(f)
+ cmd.SetArgs([]string{"default"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("reset default: %v", err)
+ }
+ loaded, err = core.LoadMultiAppConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.RiskControl != nil {
+ t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
+ }
+
+ stdout.Reset()
+ cmd = NewCmdConfigRiskControl(f)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("show default: %v", err)
+ }
+ if got := stdout.String(); got != "risk-control: on (source: default)\n" {
+ t.Fatalf("stdout = %q", got)
+ }
+}
+
+func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
+ AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
+ }}}); err != nil {
+ t.Fatal(err)
+ }
+
+ f, _, _, _ := cmdutil.TestFactory(t, nil)
+ cmd := NewCmdConfigRiskControl(f)
+ cmd.SetArgs([]string{"invalid"})
+ err := cmd.Execute()
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
+ }
+ if validationErr.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
+ }
+}
+
+func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
+ f := newConfigFactoryWithExternalProvider(t)
+ config := &core.MultiAppConfig{Apps: []core.AppConfig{{
+ AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
+ }}}
+ if err := core.SaveMultiAppConfig(config); err != nil {
+ t.Fatal(err)
+ }
+
+ cmd := NewCmdConfig(f)
+ cmd.SetArgs([]string{"risk-control", "off"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("set off with external credentials: %v", err)
+ }
+
+ loaded, err := core.LoadMultiAppConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.RiskControl == nil || *loaded.RiskControl {
+ t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
+ }
+}
diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go
index bfd1d126e..11a6b24c8 100644
--- a/cmd/root_integration_test.go
+++ b/cmd/root_integration_test.go
@@ -371,10 +371,11 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
- rootCmd := buildStrictModeIntegrationRootCmd(t, f)
+ catalog := strictModeFixtureCatalog()
+ rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
code := executeRootIntegration(t, f, rootCmd, []string{
- "im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
+ "fixture", "things", "create", "--data", `{"name":"probe"}`, "--as", "user", "--dry-run",
})
if code != output.ExitValidation {
diff --git a/cmd/service/service.go b/cmd/service/service.go
index f08a25249..813b9c5f7 100644
--- a/cmd/service/service.go
+++ b/cmd/service/service.go
@@ -707,20 +707,18 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
- pf := output.NewPaginatedFormatter(out, format)
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: out,
+ ErrOut: errOut,
+ CommandPath: commandPath,
+ Identity: string(pagOpts.Identity),
+ NoticeProvider: output.GetNotice,
+ })
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
- scanResult := output.ScanForSafety(commandPath, items, errOut)
- if scanResult.Blocked {
- return scanResult.BlockErr
- }
- if scanResult.Alert != nil {
- output.WriteAlertWarning(errOut, scanResult.Alert)
- }
- pf.FormatPage(items)
- return nil
+ return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
}, pagOpts)
if err != nil {
return err
diff --git a/cmd/service/service_paginate_test.go b/cmd/service/service_paginate_test.go
new file mode 100644
index 000000000..62f77b7c4
--- /dev/null
+++ b/cmd/service/service_paginate_test.go
@@ -0,0 +1,400 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/client"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/internal/output"
+)
+
+type serviceFailOnWriteWriter struct {
+ buf bytes.Buffer
+ writes int
+ failAt int
+ err error
+}
+
+func (w *serviceFailOnWriteWriter) Write(p []byte) (int, error) {
+ w.writes++
+ if w.writes == w.failAt {
+ return 0, w.err
+ }
+ return w.buf.Write(p)
+}
+
+func newServicePaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
+ t.Helper()
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ previousNotice := output.PendingNotice
+ output.PendingNotice = nil
+ t.Cleanup(func() { output.PendingNotice = previousNotice })
+
+ config := &core.CliConfig{
+ AppID: "test-app",
+ AppSecret: "test-secret",
+ Brand: core.BrandFeishu,
+ }
+ f, out, errOut, reg := cmdutil.TestFactory(t, config)
+ ac, err := f.NewAPIClientWithConfig(config)
+ if err != nil {
+ t.Fatalf("NewAPIClientWithConfig() error = %v", err)
+ }
+ ac.ErrOut = io.Discard
+ return ac, out, errOut, reg
+}
+
+func servicePaginateRequest() client.RawApiRequest {
+ return client.RawApiRequest{
+ Method: "GET",
+ URL: "/open-apis/test/v1/items",
+ As: core.AsBot,
+ }
+}
+
+func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
+ t.Helper()
+ wantBytes, err := json.MarshalIndent(want, "", " ")
+ if err != nil {
+ t.Fatalf("marshal expected JSON: %v", err)
+ }
+ wantBytes = append(wantBytes, '\n')
+ if !bytes.Equal(got, wantBytes) {
+ t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
+ }
+}
+
+func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
+ ac, out, errOut, reg := newServicePaginateTestHarness(t)
+ calls := 0
+ wantTokens := []string{"", "next-1", "next-2"}
+ for i, wantToken := range wantTokens {
+ page := i + 1
+ hasMore := page < len(wantTokens)
+ data := map[string]interface{}{
+ "items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
+ "has_more": hasMore,
+ }
+ if hasMore {
+ data["page_token"] = wantTokens[page]
+ }
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ OnMatch: func(req *http.Request) {
+ calls++
+ if got := req.URL.Query().Get("page_token"); got != wantToken {
+ t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
+ }
+ },
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": data,
+ },
+ })
+ }
+
+ err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
+ output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
+ PageLimit: 10,
+ PageDelay: -1,
+ }, ac.CheckResponse)
+
+ if err != nil {
+ t.Fatalf("servicePaginate() error = %v, want nil", err)
+ }
+ if calls != 3 {
+ t.Fatalf("pagination requests = %d, want 3", calls)
+ }
+ assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
+ OK: true,
+ Identity: "bot",
+ Data: map[string]interface{}{
+ "items": []interface{}{
+ map[string]interface{}{"id": "1"},
+ map[string]interface{}{"id": "2"},
+ map[string]interface{}{"id": "3"},
+ },
+ "has_more": false,
+ },
+ })
+ if got := errOut.String(); got != "" {
+ t.Fatalf("stderr bytes = %q, want empty", got)
+ }
+}
+
+func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
+ tests := []struct {
+ name string
+ format output.Format
+ want string
+ }{
+ {
+ name: "ndjson",
+ format: output.FormatNDJSON,
+ want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
+ },
+ {
+ name: "table",
+ format: output.FormatTable,
+ want: "id name \n── ─────\n1 Alice\n2 Carol\n",
+ },
+ {
+ name: "csv",
+ format: output.FormatCSV,
+ want: "id,name\n1,Alice\n2,Carol\n",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ac, out, errOut, reg := newServicePaginateTestHarness(t)
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "items": []interface{}{
+ map[string]interface{}{"id": "1", "name": "Alice"},
+ },
+ "has_more": true,
+ "page_token": "next-1",
+ },
+ },
+ })
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "items": []interface{}{
+ map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
+ },
+ "has_more": false,
+ },
+ },
+ })
+
+ err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
+ tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
+ PageLimit: 10,
+ PageDelay: -1,
+ }, ac.CheckResponse)
+
+ if err != nil {
+ t.Fatalf("servicePaginate() error = %v, want nil", err)
+ }
+ if got := out.String(); got != tt.want {
+ t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
+ }
+ if got := errOut.String(); got != "" {
+ t.Fatalf("stderr bytes = %q, want empty", got)
+ }
+ })
+ }
+}
+
+func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
+ ac, _, errOut, reg := newServicePaginateTestHarness(t)
+ sentinel := errors.New("page write failed")
+ out := &serviceFailOnWriteWriter{failAt: 2, err: sentinel}
+ calls := 0
+ for page := 1; page <= 2; page++ {
+ hasMore := true
+ data := map[string]interface{}{
+ "items": []interface{}{map[string]interface{}{"id": page}},
+ "has_more": hasMore,
+ }
+ if hasMore {
+ data["page_token"] = fmt.Sprintf("next-%d", page)
+ }
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ OnMatch: func(*http.Request) {
+ calls++
+ },
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": data,
+ },
+ })
+ }
+
+ err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
+ output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
+ client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
+
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("servicePaginate() error = %v, want preserved writer cause", err)
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryInternal {
+ t.Fatalf("servicePaginate() problem = %#v, %v; want internal typed error", problem, ok)
+ }
+ if calls != 2 {
+ t.Fatalf("pagination requests = %d, want 2", calls)
+ }
+ if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
+ t.Fatalf("stdout bytes = %q, want %q", got, want)
+ }
+}
+
+func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
+ ac, out, errOut, reg := newServicePaginateTestHarness(t)
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "name": "Test User",
+ "user_id": "u123",
+ },
+ },
+ })
+
+ err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
+ output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
+ client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
+
+ if err != nil {
+ t.Fatalf("servicePaginate() error = %v, want nil", err)
+ }
+ assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
+ OK: true,
+ Identity: "bot",
+ Data: map[string]interface{}{
+ "name": "Test User",
+ "user_id": "u123",
+ },
+ })
+ wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
+ if got := errOut.String(); got != wantWarning {
+ t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
+ }
+}
+
+func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
+ businessResponse := map[string]interface{}{
+ "code": 123456,
+ "msg": "fixture business error",
+ "data": map[string]interface{}{"detail": "business failed"},
+ }
+ tests := []struct {
+ name string
+ format output.Format
+ jqExpr string
+ }{
+ {name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
+ {name: "default_json", format: output.FormatJSON},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ac, out, errOut, reg := newServicePaginateTestHarness(t)
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ Body: businessResponse,
+ })
+
+ err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
+ tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
+ client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
+
+ if err == nil {
+ t.Fatal("servicePaginate() error = nil, want business error")
+ }
+ if errs.IsRaw(err) {
+ t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
+ }
+ assertServicePaginateJSONBytes(t, out.Bytes(), businessResponse)
+ if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
+ t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
+ }
+ if got := errOut.String(); got != "" {
+ t.Fatalf("stderr bytes = %q, want empty", got)
+ }
+ })
+ }
+}
+
+func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
+ tests := []struct {
+ name string
+ format output.Format
+ jqExpr string
+ }{
+ {name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
+ {name: "stream_pages", format: output.FormatNDJSON},
+ {name: "default_paginate_all", format: output.FormatJSON},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ac, out, errOut, _ := newServicePaginateTestHarness(t)
+
+ err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
+ tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
+ client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
+
+ if err == nil {
+ t.Fatal("servicePaginate() error = nil, want transport error")
+ }
+ if errs.IsRaw(err) {
+ t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
+ }
+ if got := out.String(); got != "" {
+ t.Fatalf("stdout bytes = %q, want empty", got)
+ }
+ if got := errOut.String(); got != "" {
+ t.Fatalf("stderr bytes = %q, want empty", got)
+ }
+ })
+ }
+}
+
+func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
+ ac, out, errOut, reg := newServicePaginateTestHarness(t)
+ reg.Register(&httpmock.Stub{
+ URL: "/open-apis/test/v1/items",
+ Body: map[string]interface{}{
+ "code": 123456,
+ "msg": "fixture business error",
+ "data": map[string]interface{}{},
+ },
+ })
+
+ err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
+ output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
+ client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
+
+ if err == nil {
+ t.Fatal("servicePaginate() error = nil, want business error")
+ }
+ if errs.IsRaw(err) {
+ t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
+ }
+ if got := out.String(); got != "" {
+ t.Fatalf("stdout bytes = %q, want empty", got)
+ }
+ if got := errOut.String(); got != "" {
+ t.Fatalf("stderr bytes = %q, want empty", got)
+ }
+}
diff --git a/cmd/service/testmain_test.go b/cmd/service/testmain_test.go
new file mode 100644
index 000000000..2b5cff27d
--- /dev/null
+++ b/cmd/service/testmain_test.go
@@ -0,0 +1,39 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package service
+
+import (
+ "os"
+ "testing"
+
+ "github.com/larksuite/cli/internal/registry/registrytest"
+)
+
+// TestMain isolates service command tests from the host machine: config (and
+// the registry cache under it) is redirected to a temp dir, then the registry
+// is seeded from the tracked fixture and initialized eagerly. Tests pass on a
+// clean checkout with no network, no `make fetch_meta`, and no user cache.
+//
+// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
+// m.Run before exiting.
+func TestMain(m *testing.M) {
+ root, err := os.MkdirTemp("", "lark-cli-cmd-service-test-*")
+ if err != nil {
+ println("cmd/service test setup: MkdirTemp failed:", err.Error())
+ os.Exit(2)
+ }
+ if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
+ println("cmd/service test setup: Setenv failed:", err.Error())
+ os.RemoveAll(root)
+ os.Exit(2)
+ }
+ if err := registrytest.Seed(root); err != nil {
+ println("cmd/service test setup: registrytest.Seed failed:", err.Error())
+ os.RemoveAll(root)
+ os.Exit(2)
+ }
+ code := m.Run()
+ os.RemoveAll(root)
+ os.Exit(code)
+}
diff --git a/cmd/startup_brand_test.go b/cmd/startup_brand_test.go
index dd7fe687f..4458db039 100644
--- a/cmd/startup_brand_test.go
+++ b/cmd/startup_brand_test.go
@@ -5,6 +5,7 @@ package cmd
import (
"context"
+ "flag"
"fmt"
"os"
"os/exec"
@@ -12,11 +13,34 @@ import (
"strings"
"testing"
+ "github.com/google/uuid"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
)
+const startupBrandHelperEnv = "GO_TEST_STARTUP_BRAND_HELPER"
+
+var _ = flag.String("startup-brand-helper", "", "internal startup brand test helper nonce")
+
+func isStartupBrandHelper() bool {
+ return startupBrandHelperEnabled(os.Getenv(startupBrandHelperEnv), startupBrandHelperNonce(os.Args))
+}
+
+func startupBrandHelperEnabled(envNonce, argNonce string) bool {
+ return envNonce != "" && envNonce == argNonce
+}
+
+func startupBrandHelperNonce(args []string) string {
+ const prefix = "-startup-brand-helper="
+ for _, arg := range args {
+ if strings.HasPrefix(arg, prefix) {
+ return strings.TrimPrefix(arg, prefix)
+ }
+ }
+ return ""
+}
+
func TestResolveStartupBrand_Precedence(t *testing.T) {
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
@@ -54,7 +78,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
// sync.Once, so the brand must be injected before the first catalog access.
// It runs in a subprocess because the registry is process-global.
func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
- if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
+ if isStartupBrandHelper() {
// Helper: replicate Execute()'s build wiring with a lark config.
buildInternal(
context.Background(), cmdutil.InvocationContext{},
@@ -71,9 +95,11 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Fatal(err)
}
+ nonce := uuid.NewString()
+ t.Setenv(startupBrandHelperEnv, nonce)
cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder")
+ cmd.Args = append(cmd.Args, "-startup-brand-helper="+nonce)
cmd.Env = append(os.Environ(),
- "GO_TEST_STARTUP_BRAND_HELPER=1",
"LARKSUITE_CLI_CONFIG_DIR="+tmp,
"LARKSUITE_CLI_REMOTE_META=off", // no network during the subprocess build
)
@@ -85,3 +111,33 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Errorf("registry brand after real startup order = %s, want lark", out)
}
}
+
+func TestStartupBrandHelperRequiresMatchingCommandNonce(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ envNonce string
+ argNonce string
+ want bool
+ }{
+ {name: "neither set"},
+ {name: "ambient environment only", envNonce: "ambient"},
+ {name: "command argument only", argNonce: "command"},
+ {name: "mismatch", envNonce: "ambient", argNonce: "command"},
+ {name: "matching", envNonce: "nonce", argNonce: "nonce", want: true},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := startupBrandHelperEnabled(tt.envNonce, tt.argNonce); got != tt.want {
+ t.Fatalf("startupBrandHelperEnabled() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestStartupBrandHelperNonce(t *testing.T) {
+ if got := startupBrandHelperNonce([]string{"test", "-test.run", "brand"}); got != "" {
+ t.Fatalf("startupBrandHelperNonce() = %q, want empty", got)
+ }
+ if got := startupBrandHelperNonce([]string{"test", "-startup-brand-helper=nonce"}); got != "nonce" {
+ t.Fatalf("startupBrandHelperNonce() = %q, want nonce", got)
+ }
+}
diff --git a/cmd/testmain_test.go b/cmd/testmain_test.go
new file mode 100644
index 000000000..bad6f4246
--- /dev/null
+++ b/cmd/testmain_test.go
@@ -0,0 +1,46 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+ "os"
+ "testing"
+
+ "github.com/larksuite/cli/internal/registry/registrytest"
+)
+
+// TestMain isolates command-tree tests from the host machine: config (and the
+// registry cache under it) is redirected to a temp dir, then the registry is
+// seeded from the tracked fixture and initialized eagerly. Tests pass on a
+// clean checkout with no network, no `make fetch_meta`, and no user cache.
+//
+// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
+// m.Run before exiting.
+func TestMain(m *testing.M) {
+ if isStartupBrandHelper() {
+ // Re-exec helper subprocess (startup_brand_test.go): the parent test
+ // already provides an isolated config dir and disables remote metadata,
+ // and the helper must own the first registry Init to prove the startup
+ // order — do not seed or eagerly initialize here.
+ os.Exit(m.Run())
+ }
+ root, err := os.MkdirTemp("", "lark-cli-cmd-test-*")
+ if err != nil {
+ println("cmd test setup: MkdirTemp failed:", err.Error())
+ os.Exit(2)
+ }
+ if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
+ println("cmd test setup: Setenv failed:", err.Error())
+ os.RemoveAll(root)
+ os.Exit(2)
+ }
+ if err := registrytest.Seed(root); err != nil {
+ println("cmd test setup: registrytest.Seed failed:", err.Error())
+ os.RemoveAll(root)
+ os.Exit(2)
+ }
+ code := m.Run()
+ os.RemoveAll(root)
+ os.Exit(code)
+}
diff --git a/cmd/update/testmain_test.go b/cmd/update/testmain_test.go
new file mode 100644
index 000000000..7fdaad15b
--- /dev/null
+++ b/cmd/update/testmain_test.go
@@ -0,0 +1,23 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package cmdupdate
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestMain(m *testing.M) {
+ root, err := os.MkdirTemp("", "lark-cli-update-test-*")
+ if err != nil {
+ panic(err)
+ }
+ if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
+ panic(err)
+ }
+ code := m.Run()
+ _ = os.RemoveAll(root)
+ os.Exit(code)
+}
diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go
index a9bbd6102..3df74b040 100644
--- a/cmd/update/update_test.go
+++ b/cmd/update/update_test.go
@@ -24,6 +24,8 @@ import (
"github.com/larksuite/cli/internal/skillscheck"
)
+const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS"
+
// newTestFactory creates a test factory with minimal config.
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
@@ -31,13 +33,17 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
return f, stdout, stderr
}
-// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
+// mockDetect sets up newUpdater to return an Updater with the given DetectResult
+// and fully mocked skills operations. Tests that only care about install-method
+// detection must never fall through to the real npx skills CLI.
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
+ u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
+ u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
@@ -104,6 +110,18 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
}
}
+func mockSkillsSync(t *testing.T) {
+ t.Helper()
+ origNew := newUpdater
+ newUpdater = func() *selfupdate.Updater {
+ u := selfupdate.New()
+ u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
+ u.SkillsCommandOverride = successfulSkillsCommand()
+ return u
+ }
+ t.Cleanup(func() { newUpdater = origNew })
+}
+
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -228,6 +246,9 @@ func TestNormalizeVersion(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ mockSkillsSync(t)
+
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -256,6 +277,9 @@ func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ mockSkillsSync(t)
+
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -281,6 +305,7 @@ func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
}
func TestUpdateManual_JSON(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -312,6 +337,7 @@ func TestUpdateManual_JSON(t *testing.T) {
}
func TestUpdateManual_Human(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
@@ -1161,6 +1187,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
}
called := false
updater := &selfupdate.Updater{
+ SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return successfulSkillsCommand()(args...)
@@ -1177,7 +1204,10 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
- updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
+ updater := &selfupdate.Updater{
+ SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
+ SkillsCommandOverride: successfulSkillsCommand(),
+ }
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
@@ -1197,6 +1227,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
t.Fatal(err)
}
updater := &selfupdate.Updater{
+ SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("npx failed")
@@ -1513,28 +1544,133 @@ func TestEmitSkillsTextHints_Success(t *testing.T) {
}
}
-// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
-// verifies "lark-cli update" correctly triggers skills sync and rewrites the
-// state file. It calls the real npx skills CLI, so the test is skipped when
-// npx or the skills registry is unavailable (e.g. no network or fork PRs).
-func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
- // Phase 1: Verify the real npx skills CLI is available; skip otherwise.
- if _, err := exec.LookPath("npx"); err != nil {
- t.Skipf("npx not found in PATH: %v", err)
+// liveSkillsIsolationEnv is the single source of truth for the user-state
+// directories a live skills test must redirect under the temporary home. It
+// covers the CLI's own config, the agent homes the skills CLI installs into,
+// the XDG dirs it derives paths from (XDG_STATE_HOME holds its global
+// .skill-lock.json), and the npm/npx overrides that take precedence over
+// HOME-derived defaults (both cases: npm reads npm_config_* case-insensitively).
+func liveSkillsIsolationEnv(home string) map[string]string {
+ return map[string]string{
+ "HOME": home,
+ "USERPROFILE": home,
+ "APPDATA": filepath.Join(home, "AppData", "Roaming"),
+ "LOCALAPPDATA": filepath.Join(home, "AppData", "Local"),
+ "XDG_CONFIG_HOME": filepath.Join(home, ".config"),
+ "XDG_DATA_HOME": filepath.Join(home, ".local", "share"),
+ "XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
+ "CODEX_HOME": filepath.Join(home, ".codex"),
+ "CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude"),
+ "LARKSUITE_CLI_CONFIG_DIR": filepath.Join(home, ".lark-cli"),
+ "npm_config_cache": filepath.Join(home, ".npm-cache"),
+ "NPM_CONFIG_CACHE": filepath.Join(home, ".npm-cache"),
+ "npm_config_prefix": filepath.Join(home, ".npm-global"),
+ "NPM_CONFIG_PREFIX": filepath.Join(home, ".npm-global"),
+ "npm_config_userconfig": filepath.Join(home, ".npmrc"),
+ "NPM_CONFIG_USERCONFIG": filepath.Join(home, ".npmrc"),
}
- ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
+}
+
+func prepareLiveSkillsIntegration(t *testing.T) string {
+ t.Helper()
+ if os.Getenv(runLiveSkillsTestsEnv) != "1" {
+ t.Skipf("live skills integration test disabled; set %s=1 to run", runLiveSkillsTestsEnv)
+ }
+
+ home := t.TempDir()
+ for key, value := range liveSkillsIsolationEnv(home) {
+ t.Setenv(key, value)
+ }
+ return home
+}
+
+func TestPrepareLiveSkillsIntegration(t *testing.T) {
+ reachedAfterGate := false
+ t.Run("requires explicit opt-in", func(t *testing.T) {
+ t.Setenv(runLiveSkillsTestsEnv, "")
+ prepareLiveSkillsIntegration(t)
+ reachedAfterGate = true
+ })
+ if reachedAfterGate {
+ t.Fatal("prepareLiveSkillsIntegration continued without explicit opt-in")
+ }
+
+ t.Run("isolates user directories", func(t *testing.T) {
+ t.Setenv(runLiveSkillsTestsEnv, "1")
+ home := prepareLiveSkillsIntegration(t)
+ // Pin the isolation contract by key: removing a variable from
+ // liveSkillsIsolationEnv must fail this list, and every redirected
+ // value must live under the temporary home.
+ required := []string{
+ "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
+ "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME",
+ "CODEX_HOME", "CLAUDE_CONFIG_DIR", "LARKSUITE_CLI_CONFIG_DIR",
+ "npm_config_cache", "NPM_CONFIG_CACHE",
+ "npm_config_prefix", "NPM_CONFIG_PREFIX",
+ "npm_config_userconfig", "NPM_CONFIG_USERCONFIG",
+ }
+ env := liveSkillsIsolationEnv(home)
+ for _, key := range required {
+ expected, ok := env[key]
+ if !ok {
+ t.Errorf("liveSkillsIsolationEnv dropped required key %s", key)
+ continue
+ }
+ if !strings.HasPrefix(expected, home) {
+ t.Errorf("%s = %q escapes temporary home %q", key, expected, home)
+ }
+ if got := os.Getenv(key); got != expected {
+ t.Errorf("%s = %q, want %q", key, got, expected)
+ }
+ }
+ })
+}
+
+// seedLiveSkillsGlobal verifies the real npx skills CLI is reachable, installs
+// lark-calendar into the isolated global skills dir, and returns the parsed
+// global skills list. The caller opted in explicitly, so every missing
+// precondition is a hard failure — skipping would report "nothing verified"
+// as a green run.
+func seedLiveSkillsGlobal(t *testing.T) []string {
+ t.Helper()
+ if _, err := exec.LookPath("npx"); err != nil {
+ t.Fatalf("live skills tests opted in but npx not found in PATH: %v", err)
+ }
+ // Three sequential npx runs against a cold cache (the isolated home starts
+ // empty) can be slow; with Fatal-on-timeout semantics the budget errs on
+ // the generous side.
+ ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
- t.Skipf("real skills CLI unavailable: %v", err)
+ t.Fatalf("live skills tests opted in but real skills CLI unavailable: %v", err)
+ }
+ if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "-s", "lark-calendar", "-g", "-y").Run(); err != nil {
+ t.Fatalf("failed to seed isolated global skills: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
- t.Skipf("real global skills CLI unavailable: %v", err)
+ t.Fatalf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
- if err := ctx.Err(); err != nil {
- t.Skipf("real skills CLI availability check timed out: %v", err)
+ if len(localSkills) == 0 {
+ t.Fatal("seeded lark-calendar but global skills list is empty")
}
+ if err := ctx.Err(); err != nil {
+ t.Fatalf("real skills CLI availability check timed out: %v", err)
+ }
+ return localSkills
+}
+
+// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
+// verifies "lark-cli update" correctly triggers skills sync and rewrites the
+// state file. It calls the real npx skills CLI and only runs with explicit
+// opt-in. All user directories are redirected to a temporary home.
+func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
+ prepareLiveSkillsIntegration(t)
+
+ // Phase 1: Verify the real npx skills CLI is available and seed the
+ // isolated global skills install.
+ localSkills := seedLiveSkillsGlobal(t)
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
@@ -1630,26 +1766,17 @@ func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// not exist (cold start), the update command installs all official skills and
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
// because there is no previous state to preserve user deletions from.
-// This is a live integration test that calls the real npx skills CLI; it is
-// skipped when npx or the skills registry is unavailable.
+// This is a live integration test that calls the real npx skills CLI and only
+// runs with explicit opt-in. All user directories are redirected to a temporary
+// home.
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
- // Phase 1: Verify the real npx skills CLI is available; skip otherwise.
- if _, err := exec.LookPath("npx"); err != nil {
- t.Skipf("npx not found in PATH: %v", err)
- }
- ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
- defer cancel()
- if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
- t.Skipf("real skills CLI unavailable: %v", err)
- }
- globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
- if err != nil {
- t.Skipf("real global skills CLI unavailable: %v", err)
- }
- localSkills := skillscheck.ParseSkillsList(string(globalOut))
- if err := ctx.Err(); err != nil {
- t.Skipf("real skills CLI availability check timed out: %v", err)
- }
+ prepareLiveSkillsIntegration(t)
+
+ // Phase 1: Verify the real npx skills CLI is available and seed one known
+ // official skill into the isolated global install. Cold start means no
+ // skills-state.json — locally installed skills may still exist, and seeding
+ // one keeps the Phase 4 per-skill assertions from running zero times.
+ localSkills := seedLiveSkillsGlobal(t)
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
diff --git a/events/application/menu.go b/events/application/menu.go
new file mode 100644
index 000000000..f77dec6d8
--- /dev/null
+++ b/events/application/menu.go
@@ -0,0 +1,107 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package application
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+
+ "github.com/larksuite/cli/internal/event"
+)
+
+// BotMenuOutput is the flattened shape for application.bot.menu_v6.
+type BotMenuOutput struct {
+ Type string `json:"type" desc:"Event type; always application.bot.menu_v6"`
+ EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
+ Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
+ AppID string `json:"app_id,omitempty" desc:"Application ID from the event header"`
+ TenantKey string `json:"tenant_key,omitempty" desc:"Tenant key from the event header"`
+ EventKey string `json:"event_key,omitempty" desc:"Developer-defined bot menu event key"`
+ MenuTimestamp string `json:"menu_timestamp,omitempty" desc:"Menu click timestamp from the event body" kind:"timestamp_ms"`
+ OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id; kept as a short alias of operator_open_id" kind:"open_id"`
+ OperatorOpenID string `json:"operator_open_id,omitempty" desc:"Operator open_id" kind:"open_id"`
+ OperatorUnionID string `json:"operator_union_id,omitempty" desc:"Operator union_id" kind:"union_id"`
+ OperatorUserID string `json:"operator_user_id,omitempty" desc:"Operator user_id" kind:"user_id"`
+ OperatorName string `json:"operator_name,omitempty" desc:"Operator display name"`
+}
+
+func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
+ var envelope struct {
+ Header struct {
+ EventID string `json:"event_id"`
+ EventType string `json:"event_type"`
+ CreateTime string `json:"create_time"`
+ AppID string `json:"app_id"`
+ TenantKey string `json:"tenant_key"`
+ } `json:"header"`
+ Event struct {
+ EventKey string `json:"event_key"`
+ Timestamp json.RawMessage `json:"timestamp"`
+ Operator struct {
+ OperatorID struct {
+ OpenID string `json:"open_id"`
+ UnionID string `json:"union_id"`
+ UserID string `json:"user_id"`
+ } `json:"operator_id"`
+ OperatorName string `json:"operator_name"`
+ } `json:"operator"`
+ } `json:"event"`
+ }
+ if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
+ return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
+ }
+
+ menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
+ timestamp := envelope.Header.CreateTime
+ if timestamp == "" {
+ timestamp = menuTimestamp
+ }
+ operatorID := envelope.Event.Operator.OperatorID.OpenID
+
+ out := &BotMenuOutput{
+ Type: eventTypeBotMenuV6,
+ EventID: envelope.Header.EventID,
+ Timestamp: timestamp,
+ AppID: envelope.Header.AppID,
+ TenantKey: envelope.Header.TenantKey,
+ EventKey: envelope.Event.EventKey,
+ MenuTimestamp: menuTimestamp,
+ OperatorID: operatorID,
+ OperatorOpenID: operatorID,
+ OperatorUnionID: envelope.Event.Operator.OperatorID.UnionID,
+ OperatorUserID: envelope.Event.Operator.OperatorID.UserID,
+ OperatorName: envelope.Event.Operator.OperatorName,
+ }
+ return json.Marshal(out)
+}
+
+func rawScalarString(raw json.RawMessage) string {
+ s := strings.TrimSpace(string(raw))
+ if s == "" || s == "null" {
+ return ""
+ }
+ var text string
+ if err := json.Unmarshal(raw, &text); err == nil {
+ return text
+ }
+ return s
+}
+
+func timestampMillisString(raw json.RawMessage) string {
+ s := rawScalarString(raw)
+ if len(s) == 10 && allDigits(s) {
+ return s + "000"
+ }
+ return s
+}
+
+func allDigits(s string) bool {
+ for _, r := range s {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return s != ""
+}
diff --git a/events/application/menu_test.go b/events/application/menu_test.go
new file mode 100644
index 000000000..1a9c5be1f
--- /dev/null
+++ b/events/application/menu_test.go
@@ -0,0 +1,227 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package application
+
+import (
+ "context"
+ "encoding/json"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/larksuite/cli/internal/event"
+)
+
+func TestKeysBotMenuMetadata(t *testing.T) {
+ keys := Keys()
+ if len(keys) != 1 {
+ t.Fatalf("len(Keys()) = %d, want 1", len(keys))
+ }
+
+ def := keys[0]
+ if def.Key != eventTypeBotMenuV6 {
+ t.Errorf("Key = %q, want %q", def.Key, eventTypeBotMenuV6)
+ }
+ if def.EventType != eventTypeBotMenuV6 {
+ t.Errorf("EventType = %q, want %q", def.EventType, eventTypeBotMenuV6)
+ }
+ if def.SubscriptionType != "" {
+ t.Errorf("SubscriptionType = %q, want default event subscription", def.SubscriptionType)
+ }
+ if def.Schema.Custom == nil {
+ t.Fatal("Schema.Custom is nil")
+ }
+ if def.Schema.Custom.Type != reflect.TypeOf(BotMenuOutput{}) {
+ t.Errorf("custom type = %v, want BotMenuOutput", def.Schema.Custom.Type)
+ }
+ if def.Schema.Native != nil {
+ t.Fatal("Schema.Native must be nil for processed output")
+ }
+ if def.Process == nil {
+ t.Fatal("Process is nil")
+ }
+ if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
+ t.Errorf("AuthTypes = %#v", def.AuthTypes)
+ }
+ if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeBotMenuV6}) {
+ t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
+ }
+}
+
+func TestBotMenuRegistersCleanly(t *testing.T) {
+ const key = eventTypeBotMenuV6
+ event.UnregisterKeyForTest(key)
+ t.Cleanup(func() { event.UnregisterKeyForTest(key) })
+
+ for _, def := range Keys() {
+ event.RegisterKey(def)
+ }
+ if _, ok := event.Lookup(key); !ok {
+ t.Fatalf("event.Lookup(%q) not registered", key)
+ }
+}
+
+func TestProcessBotMenu(t *testing.T) {
+ payload := `{
+ "schema": "2.0",
+ "header": {
+ "event_id": "ev_menu_001",
+ "event_type": "application.bot.menu_v6",
+ "create_time": "1776409469273",
+ "app_id": "cli_test",
+ "tenant_key": "tenant_test"
+ },
+ "event": {
+ "event_key": "start_eval",
+ "timestamp": 1776409469000,
+ "operator": {
+ "operator_id": {
+ "open_id": "ou_operator",
+ "union_id": "on_operator",
+ "user_id": "user_operator"
+ },
+ "operator_name": "Test User"
+ }
+ }
+ }`
+ out := runBotMenu(t, payload)
+
+ if out.Type != eventTypeBotMenuV6 {
+ t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
+ }
+ if out.EventID != "ev_menu_001" {
+ t.Errorf("EventID = %q", out.EventID)
+ }
+ if out.Timestamp != "1776409469273" {
+ t.Errorf("Timestamp = %q", out.Timestamp)
+ }
+ if out.EventKey != "start_eval" {
+ t.Errorf("EventKey = %q", out.EventKey)
+ }
+ if out.MenuTimestamp != "1776409469000" {
+ t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
+ }
+ if out.OperatorID != "ou_operator" || out.OperatorOpenID != "ou_operator" {
+ t.Errorf("OperatorID/OperatorOpenID = %q/%q", out.OperatorID, out.OperatorOpenID)
+ }
+ if out.OperatorUnionID != "on_operator" {
+ t.Errorf("OperatorUnionID = %q", out.OperatorUnionID)
+ }
+ if out.OperatorUserID != "user_operator" {
+ t.Errorf("OperatorUserID = %q", out.OperatorUserID)
+ }
+ if out.OperatorName != "Test User" {
+ t.Errorf("OperatorName = %q", out.OperatorName)
+ }
+ if out.AppID != "cli_test" || out.TenantKey != "tenant_test" {
+ t.Errorf("AppID/TenantKey = %q/%q", out.AppID, out.TenantKey)
+ }
+}
+
+func TestProcessBotMenuStringTimestampFallback(t *testing.T) {
+ payload := `{
+ "schema": "2.0",
+ "header": {
+ "event_id": "ev_menu_002",
+ "event_type": "application.bot.menu_v6"
+ },
+ "event": {
+ "event_key": "start_eval",
+ "timestamp": "1776409469001",
+ "operator": {
+ "operator_id": {"open_id": "ou_operator"}
+ }
+ }
+ }`
+ out := runBotMenu(t, payload)
+
+ if out.Timestamp != "1776409469001" {
+ t.Errorf("Timestamp fallback = %q", out.Timestamp)
+ }
+ if out.MenuTimestamp != "1776409469001" {
+ t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
+ }
+}
+
+func TestProcessBotMenuSecondsTimestampFallback(t *testing.T) {
+ payload := `{
+ "schema": "2.0",
+ "header": {
+ "event_id": "ev_menu_seconds",
+ "event_type": "application.bot.menu_v6"
+ },
+ "event": {
+ "event_key": "start_eval",
+ "timestamp": 1694592375,
+ "operator": {
+ "operator_id": {"open_id": "ou_operator"}
+ }
+ }
+ }`
+ out := runBotMenu(t, payload)
+
+ if out.Timestamp != "1694592375000" {
+ t.Errorf("Timestamp fallback = %q, want seconds normalized to milliseconds", out.Timestamp)
+ }
+ if out.MenuTimestamp != "1694592375000" {
+ t.Errorf("MenuTimestamp = %q, want seconds normalized to milliseconds", out.MenuTimestamp)
+ }
+}
+
+func TestProcessBotMenuTypeUsesLocalConstant(t *testing.T) {
+ payload := `{
+ "schema": "2.0",
+ "header": {
+ "event_id": "ev_menu_003",
+ "event_type": "unexpected.event_type",
+ "create_time": "1776409469275"
+ },
+ "event": {
+ "event_key": "start_eval",
+ "operator": {
+ "operator_id": {"open_id": "ou_operator"}
+ }
+ }
+ }`
+ out := runBotMenu(t, payload)
+
+ if out.Type != eventTypeBotMenuV6 {
+ t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
+ }
+}
+
+func TestProcessBotMenuMalformedPayload(t *testing.T) {
+ raw := &event.RawEvent{
+ EventID: "ev_bad",
+ EventType: eventTypeBotMenuV6,
+ Payload: json.RawMessage(`not json`),
+ Timestamp: time.Now(),
+ }
+ got, err := processBotMenu(context.Background(), nil, raw, nil)
+ if err != nil {
+ t.Fatalf("Process should swallow parse errors, got %v", err)
+ }
+ if string(got) != "not json" {
+ t.Errorf("malformed fallback output = %q, want original bytes", string(got))
+ }
+}
+
+func runBotMenu(t *testing.T, payload string) BotMenuOutput {
+ t.Helper()
+ raw := &event.RawEvent{
+ EventID: "ev_test",
+ EventType: eventTypeBotMenuV6,
+ Payload: json.RawMessage(payload),
+ Timestamp: time.Now(),
+ }
+ got, err := processBotMenu(context.Background(), nil, raw, nil)
+ if err != nil {
+ t.Fatalf("processBotMenu: %v", err)
+ }
+ var out BotMenuOutput
+ if err := json.Unmarshal(got, &out); err != nil {
+ t.Fatalf("unmarshal output: %v\n%s", err, got)
+ }
+ return out
+}
diff --git a/events/application/register.go b/events/application/register.go
new file mode 100644
index 000000000..63b8a5831
--- /dev/null
+++ b/events/application/register.go
@@ -0,0 +1,31 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+// Package application registers Application-domain EventKeys.
+package application
+
+import (
+ "reflect"
+
+ "github.com/larksuite/cli/internal/event"
+)
+
+const eventTypeBotMenuV6 = "application.bot.menu_v6"
+
+// Keys returns all Application-domain EventKey definitions.
+func Keys() []event.KeyDefinition {
+ return []event.KeyDefinition{
+ {
+ Key: eventTypeBotMenuV6,
+ DisplayName: "Bot menu",
+ Description: "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.",
+ EventType: eventTypeBotMenuV6,
+ Schema: event.SchemaDef{
+ Custom: &event.SchemaSpec{Type: reflect.TypeOf(BotMenuOutput{})},
+ },
+ Process: processBotMenu,
+ AuthTypes: []string{"bot"},
+ RequiredConsoleEvents: []string{eventTypeBotMenuV6},
+ },
+ }
+}
diff --git a/events/register.go b/events/register.go
index b4abd0a4b..b45406bfc 100644
--- a/events/register.go
+++ b/events/register.go
@@ -5,6 +5,7 @@
package events
import (
+ "github.com/larksuite/cli/events/application"
"github.com/larksuite/cli/events/approval"
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
@@ -17,6 +18,7 @@ import (
// Mail is intentionally omitted in this phase.
func init() {
all := [][]event.KeyDefinition{
+ application.Keys(),
approval.Keys(),
im.Keys(),
minutes.Keys(),
diff --git a/internal/auth/testmain_test.go b/internal/auth/testmain_test.go
new file mode 100644
index 000000000..ac2c8b0d4
--- /dev/null
+++ b/internal/auth/testmain_test.go
@@ -0,0 +1,23 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestMain(m *testing.M) {
+ root, err := os.MkdirTemp("", "lark-cli-internal-auth-test-*")
+ if err != nil {
+ panic(err)
+ }
+ if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
+ panic(err)
+ }
+ code := m.Run()
+ _ = os.RemoveAll(root)
+ os.Exit(code)
+}
diff --git a/internal/client/response.go b/internal/client/response.go
index f92160e99..36ddc1ca3 100644
--- a/internal/client/response.go
+++ b/internal/client/response.go
@@ -132,16 +132,14 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
})
}
- // Content safety scanning for non-JSON presentation formats.
- scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
- if scanResult.Blocked {
- return scanResult.BlockErr
- }
- if scanResult.Alert != nil {
- output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
- }
- output.FormatValue(opts.Out, result, opts.Format)
- return nil
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: opts.Out,
+ ErrOut: opts.ErrOut,
+ CommandPath: opts.CommandPath,
+ Identity: string(identity),
+ NoticeProvider: output.GetNotice,
+ })
+ return emitter.Success(result, output.EmitOptions{Format: opts.Format.String()})
}
// Non-JSON (binary) responses.
diff --git a/internal/client/response_test.go b/internal/client/response_test.go
index 25c483fe9..28ae28265 100644
--- a/internal/client/response_test.go
+++ b/internal/client/response_test.go
@@ -18,6 +18,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
@@ -239,6 +240,87 @@ func TestHandleResponse_JSON(t *testing.T) {
}
}
+func TestHandleResponse_NonJSONFormatsEmitExactStructuredResponseBytes(t *testing.T) {
+ tests := []struct {
+ name string
+ format output.Format
+ want string
+ }{
+ {
+ name: "ndjson",
+ format: output.FormatNDJSON,
+ want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Bob\"}\n",
+ },
+ {
+ name: "table",
+ format: output.FormatTable,
+ want: "id name \n── ─────\n1 Alice\n2 Bob \n",
+ },
+ {
+ name: "csv",
+ format: output.FormatCSV,
+ want: "id,name\n1,Alice\n2,Bob\n",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ reg := &httpmock.Registry{}
+ reg.Register(&httpmock.Stub{
+ Method: http.MethodGet,
+ URL: "/open-apis/test/v1/items",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "items": []interface{}{
+ map[string]interface{}{"id": "1", "name": "Alice"},
+ map[string]interface{}{"id": "2", "name": "Bob"},
+ },
+ "has_more": false,
+ },
+ },
+ })
+
+ httpResp, err := httpmock.NewClient(reg).Get("https://open.feishu.cn/open-apis/test/v1/items")
+ if err != nil {
+ t.Fatalf("fixture request failed: %v", err)
+ }
+ body, err := io.ReadAll(httpResp.Body)
+ _ = httpResp.Body.Close()
+ if err != nil {
+ t.Fatalf("read fixture response: %v", err)
+ }
+ resp := &larkcore.ApiResp{
+ StatusCode: httpResp.StatusCode,
+ Header: httpResp.Header.Clone(),
+ RawBody: body,
+ }
+
+ var out bytes.Buffer
+ var errOut bytes.Buffer
+ err = HandleResponse(resp, ResponseOptions{
+ Format: tt.format,
+ Identity: core.AsBot,
+ Out: &out,
+ ErrOut: &errOut,
+ CommandPath: "lark-cli api GET",
+ })
+ if err != nil {
+ t.Fatalf("HandleResponse() error = %v, want nil", err)
+ }
+ if got := out.String(); got != tt.want {
+ t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
+ }
+ if got := errOut.String(); got != "" {
+ t.Fatalf("stderr bytes = %q, want empty", got)
+ }
+ reg.Verify(t)
+ })
+ }
+}
+
func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) {
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go
index 2051e0bea..9527346a7 100644
--- a/internal/cmdutil/factory_default.go
+++ b/internal/cmdutil/factory_default.go
@@ -22,6 +22,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
+ "github.com/larksuite/cli/internal/riskcontrol"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
@@ -33,7 +34,7 @@ import (
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
-// Phase 4: LarkClient derived from Credential
+// Phase 4: LarkClient derived from Credential and workspace policy
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
@@ -54,9 +55,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
+ workspaceConfig := core.NewConfigSnapshot()
// Phase 1: HttpClient (no credential dependency)
- f.HttpClient = cachedHttpClientFunc(f)
+ f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
@@ -67,7 +69,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
ErrOut: f.IOStreams.ErrOut,
})
- // Phase 3: Config derived from Credential via an explicit conversion boundary.
+ // Phase 3: Runtime config contains resolved account data only.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -78,8 +80,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
- // Phase 4: LarkClient from Credential (placeholder AppSecret)
- f.LarkClient = cachedLarkClientFunc(f)
+ // Phase 4: LarkClient composes account data and workspace policy at the SDK
+ // transport boundary.
+ f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
return f
}
@@ -108,13 +111,16 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
-func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
+func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
+ hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
+
var rt http.RoundTripper = transport.Shared()
+ rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
@@ -128,7 +134,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
})
}
-func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
+func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -142,8 +148,15 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
+ hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
+ var sdkBase http.RoundTripper = transport.Shared()
+ // The innermost SDK boundary always strips reserved host-signal headers;
+ // a nil source makes it strip-only when workspace policy disables signal
+ // collection.
+ sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
+ sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
- Transport: buildSDKTransport(),
+ Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -152,9 +165,8 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
})
}
-func buildSDKTransport() http.RoundTripper {
- var sdkTransport http.RoundTripper = transport.Shared()
- sdkTransport = &RetryTransport{Base: sdkTransport}
+func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
+ var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
diff --git a/internal/cmdutil/factory_http_test.go b/internal/cmdutil/factory_http_test.go
index a2b50e823..0cea52878 100644
--- a/internal/cmdutil/factory_http_test.go
+++ b/internal/cmdutil/factory_http_test.go
@@ -6,10 +6,15 @@ package cmdutil
import (
"io"
"testing"
+
+ "github.com/larksuite/cli/internal/core"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
- fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
+ isEnabled := false
+ f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
+ f.IOStreams.ErrOut = io.Discard
+ fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c1, err := fn()
if err != nil {
@@ -29,7 +34,10 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
- fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
+ isEnabled := false
+ f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
+ f.IOStreams.ErrOut = io.Discard
+ fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -37,7 +45,10 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
- fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
+ isEnabled := false
+ f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
+ f.IOStreams.ErrOut = io.Discard
+ fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
diff --git a/internal/cmdutil/factory_proxy_warn_test.go b/internal/cmdutil/factory_proxy_warn_test.go
index ffdeb488f..0bf2e4bed 100644
--- a/internal/cmdutil/factory_proxy_warn_test.go
+++ b/internal/cmdutil/factory_proxy_warn_test.go
@@ -8,6 +8,7 @@ import (
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
+ "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -36,13 +37,15 @@ var proxyWarnGateCases = []struct {
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
+ isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
- fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
- ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
- }})
+ f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
+ f.IOStreams.ErrOut = io.Discard
+ f.IOStreams.StderrIsTerminal = tc.terminal
+ fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
@@ -73,7 +76,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
- if _, err := cachedLarkClientFunc(f)(); err != nil {
+ if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
t.Fatalf("lark client init: %v", err)
}
diff --git a/internal/cmdutil/localfile.go b/internal/cmdutil/localfile.go
new file mode 100644
index 000000000..8ed86d671
--- /dev/null
+++ b/internal/cmdutil/localfile.go
@@ -0,0 +1,36 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package cmdutil
+
+import (
+ "io/fs"
+
+ "github.com/larksuite/cli/extension/fileio"
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/internal/vfs"
+)
+
+// StatLocalFile returns metadata for a path in the process filesystem namespace.
+// It is intended for advisory validation; callers must validate the opened file
+// again before using its contents.
+func StatLocalFile(path string) (fs.FileInfo, error) {
+ localPath, err := validate.LocalInputPath(path)
+ if err != nil {
+ return nil, &fileio.PathValidationError{Err: err}
+ }
+ return vfs.Stat(localPath)
+}
+
+// OpenLocalFile opens a path in the process filesystem namespace.
+// Absolute and relative paths are accepted. It is the shared replacement for
+// direct os.Open/os.ReadFile use in commands that intentionally read local
+// paths outside the workspace sandbox. Callers inspect the returned descriptor
+// before reading so validation and use apply to the same opened file.
+func OpenLocalFile(path string) (fs.File, error) {
+ localPath, err := validate.LocalInputPath(path)
+ if err != nil {
+ return nil, &fileio.PathValidationError{Err: err}
+ }
+ return vfs.Open(localPath)
+}
diff --git a/internal/cmdutil/localfile_test.go b/internal/cmdutil/localfile_test.go
new file mode 100644
index 000000000..fcc3f11e7
--- /dev/null
+++ b/internal/cmdutil/localfile_test.go
@@ -0,0 +1,96 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package cmdutil
+
+import (
+ "errors"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/larksuite/cli/extension/fileio"
+ "github.com/larksuite/cli/internal/vfs"
+)
+
+func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
+ root := t.TempDir()
+ workDir := filepath.Join(root, "work")
+ if err := os.Mkdir(workDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(root, "input.txt")
+ if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ TestChdir(t, workDir)
+
+ for _, input := range []string{path, filepath.Join("..", "input.txt")} {
+ f, err := OpenLocalFile(input)
+ if err != nil {
+ t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
+ }
+ got, readErr := io.ReadAll(f)
+ closeErr := f.Close()
+ if readErr != nil || closeErr != nil || string(got) != "content" {
+ t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
+ }
+ }
+}
+
+func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
+ if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
+ t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
+ }
+}
+
+func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
+ info, err := StatLocalFile(t.TempDir())
+ if err != nil {
+ t.Fatalf("StatLocalFile() error = %v", err)
+ }
+ if !info.IsDir() {
+ t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
+ }
+}
+
+func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "input.txt")
+ if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ previous := vfs.DefaultFS
+ counting := &countingLocalFileFS{FS: previous}
+ vfs.DefaultFS = counting
+ t.Cleanup(func() { vfs.DefaultFS = previous })
+
+ f, err := OpenLocalFile(path)
+ if err != nil {
+ t.Fatalf("OpenLocalFile() error = %v", err)
+ }
+ if err := f.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if counting.openCalls != 1 || counting.statCalls != 0 {
+ t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
+ }
+}
+
+type countingLocalFileFS struct {
+ vfs.FS
+ openCalls int
+ statCalls int
+}
+
+func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
+ f.openCalls++
+ return f.FS.Open(name)
+}
+
+func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
+ f.statCalls++
+ return f.FS.Stat(name)
+}
diff --git a/internal/cmdutil/risk_control.go b/internal/cmdutil/risk_control.go
new file mode 100644
index 000000000..36479ca64
--- /dev/null
+++ b/internal/cmdutil/risk_control.go
@@ -0,0 +1,28 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package cmdutil
+
+import (
+ "github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/internal/riskcontrol"
+)
+
+type workspaceConfigSource interface {
+ MultiAppConfig() (*core.MultiAppConfig, error)
+}
+
+// resolveSDKHostSignalSource applies workspace policy at the SDK transport
+// boundary.
+func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
+ if config == nil {
+ return nil
+ }
+ workspace, configErr := config.MultiAppConfig()
+ // Default-on means an existing config with no explicit preference. Absent
+ // or unreadable config cannot authorize host-signal collection.
+ if configErr != nil || !workspace.RiskControlEnabled() {
+ return nil
+ }
+ return riskcontrol.NewHostSource()
+}
diff --git a/internal/cmdutil/risk_control_test.go b/internal/cmdutil/risk_control_test.go
new file mode 100644
index 000000000..b54c4c65b
--- /dev/null
+++ b/internal/cmdutil/risk_control_test.go
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package cmdutil
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/larksuite/cli/internal/core"
+)
+
+type staticWorkspaceConfig struct {
+ config *core.MultiAppConfig
+ err error
+}
+
+func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
+ return s.config, s.err
+}
+
+func TestResolveSDKHostSignalSource(t *testing.T) {
+ disabled := false
+ tests := []struct {
+ name string
+ config workspaceConfigSource
+ wantSource bool
+ }{
+ {name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
+ {name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
+ {name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
+ {name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
+ {name: "nil config value", config: staticWorkspaceConfig{}},
+ {name: "nil config source"},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got := resolveSDKHostSignalSource(test.config)
+ if (got != nil) != test.wantSource {
+ t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
+ }
+ })
+ }
+}
diff --git a/internal/cmdutil/testmain_test.go b/internal/cmdutil/testmain_test.go
new file mode 100644
index 000000000..814d323a9
--- /dev/null
+++ b/internal/cmdutil/testmain_test.go
@@ -0,0 +1,30 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package cmdutil
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestMain(m *testing.M) {
+ // Default-factory tests initialize the registry and resolve config. Keep
+ // them deterministic: never read the developer's real ~/.lark-cli and
+ // prevent background remote-metadata refreshes from touching user state.
+ root, err := os.MkdirTemp("", "lark-cli-cmdutil-test-*")
+ if err != nil {
+ println("internal/cmdutil test setup: MkdirTemp failed:", err.Error())
+ os.Exit(2)
+ }
+ if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
+ panic(err)
+ }
+ if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil {
+ panic(err)
+ }
+ code := m.Run()
+ _ = os.RemoveAll(root)
+ os.Exit(code)
+}
diff --git a/internal/cmdutil/transport_test.go b/internal/cmdutil/transport_test.go
index 9cd061021..7bc96658c 100644
--- a/internal/cmdutil/transport_test.go
+++ b/internal/cmdutil/transport_test.go
@@ -15,6 +15,7 @@ import (
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
+ "github.com/larksuite/cli/internal/riskcontrol"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -91,13 +92,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
// ---------------------------------------------------------------------------
-// buildSDKTransport chain composition
+// wrapSDKTransport chain composition
// ---------------------------------------------------------------------------
-func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
- transport := buildSDKTransport()
+func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
+ transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
- // Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
+ // Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -110,18 +111,23 @@ func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
- if _, ok := ua.Base.(*RetryTransport); !ok {
+ retry, ok := ua.Base.(*RetryTransport)
+ if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
+ if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
+ t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
+ }
}
-func TestBuildSDKTransport_WithExtension(t *testing.T) {
+func TestWrapSDKTransport_WithExtension(t *testing.T) {
+ previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{})
- t.Cleanup(func() { exttransport.Register(nil) })
+ t.Cleanup(func() { exttransport.Register(previous) })
- transport := buildSDKTransport()
+ transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
- // Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
+ // Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
@@ -138,17 +144,23 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
- if _, ok := ua.Base.(*RetryTransport); !ok {
+ retry, ok := ua.Base.(*RetryTransport)
+ if !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
+ if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
+ t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
+ }
}
-func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
+func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
+ previous := exttransport.GetProvider()
exttransport.Register(nil)
+ t.Cleanup(func() { exttransport.Register(previous) })
- transport := buildSDKTransport()
+ transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
- // Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
+ // Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -161,9 +173,13 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
- if _, ok := ua.Base.(*RetryTransport); !ok {
+ retry, ok := ua.Base.(*RetryTransport)
+ if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
+ if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
+ t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
+ }
}
// ---------------------------------------------------------------------------
@@ -261,6 +277,40 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
return nil
}
+type riskHeaderTamperingInterceptor struct{}
+
+func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
+ req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
+ req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
+ return nil
+}
+
+func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
+ previous := exttransport.GetProvider()
+ exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
+ t.Cleanup(func() { exttransport.Register(previous) })
+
+ var received http.Header
+ network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ received = req.Header.Clone()
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
+ })
+ req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Authorization", "Bearer token")
+
+ resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
+ t.Fatalf("extension risk headers reached network: %v", received)
+ }
+}
+
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
@@ -277,7 +327,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
- // Replicate the SDK chain layering used by buildSDKTransport.
+ // Replicate the SDK chain layering used by wrapSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}
diff --git a/internal/core/config.go b/internal/core/config.go
index 22d83c5d0..9a9606593 100644
--- a/internal/core/config.go
+++ b/internal/core/config.go
@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
StrictMode StrictMode `json:"strictMode,omitempty"`
+ RiskControl *bool `json:"riskControl,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
+// RiskControlEnabled resolves the workspace policy. An omitted preference
+// keeps the default-on account-protection behavior.
+func (m *MultiAppConfig) RiskControlEnabled() bool {
+ return m != nil && (m.RiskControl == nil || *m.RiskControl)
+}
+
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
diff --git a/internal/core/config_snapshot.go b/internal/core/config_snapshot.go
new file mode 100644
index 000000000..63977e7e2
--- /dev/null
+++ b/internal/core/config_snapshot.go
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package core
+
+import (
+ "io/fs"
+ "sync"
+)
+
+// ConfigSnapshot lazily captures one stable view of config.json for a CLI
+// invocation. All runtime consumers share the same load result so account and
+// workspace policy resolution cannot observe different file revisions. Callers
+// must treat the returned config as read-only.
+type ConfigSnapshot struct {
+ load func() (*MultiAppConfig, error)
+}
+
+// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
+func NewConfigSnapshot() *ConfigSnapshot {
+ return newConfigSnapshot(LoadMultiAppConfig)
+}
+
+func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
+ if load == nil {
+ return &ConfigSnapshot{}
+ }
+ return &ConfigSnapshot{load: sync.OnceValues(load)}
+}
+
+// MultiAppConfig returns the captured persistent config and load error.
+func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
+ if s == nil || s.load == nil {
+ return nil, fs.ErrNotExist
+ }
+ return s.load()
+}
diff --git a/internal/core/config_snapshot_test.go b/internal/core/config_snapshot_test.go
new file mode 100644
index 000000000..fb933ed96
--- /dev/null
+++ b/internal/core/config_snapshot_test.go
@@ -0,0 +1,58 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package core
+
+import (
+ "errors"
+ "io/fs"
+ "testing"
+)
+
+func TestConfigSnapshotLoadsOnce(t *testing.T) {
+ calls := 0
+ want := &MultiAppConfig{}
+ snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
+ calls++
+ return want, nil
+ })
+
+ for range 2 {
+ config, err := snapshot.MultiAppConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if config != want {
+ t.Fatal("snapshot returned a different config instance")
+ }
+ }
+ if calls != 1 {
+ t.Fatalf("config loads = %d, want 1", calls)
+ }
+}
+
+func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
+ config, err := (&ConfigSnapshot{}).MultiAppConfig()
+ if config != nil || !errors.Is(err, fs.ErrNotExist) {
+ t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
+ }
+}
+
+func TestConfigSnapshotCachesError(t *testing.T) {
+ calls := 0
+ want := errors.New("load failed")
+ snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
+ calls++
+ return nil, want
+ })
+
+ for range 2 {
+ config, err := snapshot.MultiAppConfig()
+ if config != nil || !errors.Is(err, want) {
+ t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
+ }
+ }
+ if calls != 1 {
+ t.Fatalf("config loads = %d, want 1", calls)
+ }
+}
diff --git a/internal/core/config_test.go b/internal/core/config_test.go
index b9b6fdd94..233ef94d4 100644
--- a/internal/core/config_test.go
+++ b/internal/core/config_test.go
@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
+ disabled := false
config := &MultiAppConfig{
+ RiskControl: &disabled,
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
+ if got.RiskControl == nil || *got.RiskControl {
+ t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
+ }
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
diff --git a/internal/event/testmain_test.go b/internal/event/testmain_test.go
new file mode 100644
index 000000000..c9704b036
--- /dev/null
+++ b/internal/event/testmain_test.go
@@ -0,0 +1,23 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package event
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestMain(m *testing.M) {
+ root, err := os.MkdirTemp("", "lark-cli-event-test-*")
+ if err != nil {
+ panic(err)
+ }
+ if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
+ panic(err)
+ }
+ code := m.Run()
+ _ = os.RemoveAll(root)
+ os.Exit(code)
+}
diff --git a/internal/keychain/testmain_test.go b/internal/keychain/testmain_test.go
new file mode 100644
index 000000000..4d8afd9f2
--- /dev/null
+++ b/internal/keychain/testmain_test.go
@@ -0,0 +1,28 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package keychain
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestMain(m *testing.M) {
+ root, err := os.MkdirTemp("", "lark-cli-keychain-test-*")
+ if err != nil {
+ panic(err)
+ }
+ for key, value := range map[string]string{
+ "LARKSUITE_CLI_DATA_DIR": filepath.Join(root, "data"),
+ "LARKSUITE_CLI_LOG_DIR": filepath.Join(root, "logs"),
+ } {
+ if err := os.Setenv(key, value); err != nil {
+ panic(err)
+ }
+ }
+ code := m.Run()
+ _ = os.RemoveAll(root)
+ os.Exit(code)
+}
diff --git a/internal/output/csv.go b/internal/output/csv.go
index 22be2b26a..e27312b10 100644
--- a/internal/output/csv.go
+++ b/internal/output/csv.go
@@ -7,70 +7,91 @@ import (
"encoding/csv"
"fmt"
"io"
- "os"
)
// FormatAsCSV formats data as CSV (with header) and writes it to w.
func FormatAsCSV(w io.Writer, data interface{}) {
- FormatAsCSVPaginated(w, data, true)
+ // Match the other legacy wrappers: surface only a marshal failure (as the
+ // JSON fallback historically did); plain write failures stay swallowed.
+ if err := WriteCSV(w, data); isOutputMarshalError(err) {
+ legacyStderrf("json marshal error: %v\n", err)
+ }
+}
+
+// WriteCSV formats data as CSV and returns marshal or write errors.
+func WriteCSV(w io.Writer, data interface{}) error {
+ return WriteCSVPaginated(w, data, true)
}
// FormatAsCSVPaginated formats data as CSV with pagination awareness.
// When isFirstPage is true, outputs the header row; otherwise only data rows.
func FormatAsCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) {
+ if err := WriteCSVPaginated(w, data, isFirstPage); isOutputMarshalError(err) {
+ legacyStderrf("json marshal error: %v\n", err)
+ }
+}
+
+// WriteCSVPaginated formats data as CSV and returns marshal or write errors.
+func WriteCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) error {
rows, cols, isList := prepareRows(data)
if cols == nil {
if isList {
- fmt.Fprintln(w, "(empty)")
+ _, err := fmt.Fprintln(w, "(empty)")
+ return err
} else {
- PrintJson(w, data)
+ return WriteJSON(w, data)
}
- return
}
if len(rows) == 0 {
if isFirstPage {
- fmt.Fprintln(w, "(empty)")
+ _, err := fmt.Fprintln(w, "(empty)")
+ return err
}
- return
+ return nil
}
if !isList {
// Single object: key,value rows
cw := csv.NewWriter(w)
if isFirstPage {
- cw.Write([]string{"key", "value"})
+ if err := cw.Write([]string{"key", "value"}); err != nil {
+ return err
+ }
}
for _, col := range cols {
- cw.Write([]string{col, rows[0][col]})
+ if err := cw.Write([]string{col, rows[0][col]}); err != nil {
+ return err
+ }
}
- flushCSV(cw)
- return
+ return flushCSV(cw)
}
- writeCSVRows(w, rows, cols, isFirstPage)
+ return writeCSVRows(w, rows, cols, isFirstPage)
}
// writeCSVRows writes CSV data rows (and optionally header) using the given columns.
-func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) {
+func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) error {
cw := csv.NewWriter(w)
if writeHeader {
- cw.Write(cols)
+ if err := cw.Write(cols); err != nil {
+ return err
+ }
}
for _, row := range rows {
record := make([]string, len(cols))
for i, col := range cols {
record[i] = row[col]
}
- cw.Write(record)
+ if err := cw.Write(record); err != nil {
+ return err
+ }
}
- flushCSV(cw)
+ return flushCSV(cw)
}
-// flushCSV flushes the csv.Writer and reports any write error to stderr.
-func flushCSV(cw *csv.Writer) {
+// flushCSV flushes the csv.Writer and returns any write error.
+func flushCSV(cw *csv.Writer) error {
cw.Flush()
- if err := cw.Error(); err != nil {
- fmt.Fprintf(os.Stderr, "csv write error: %v\n", err)
- }
+ return cw.Error()
}
diff --git a/internal/output/emit.go b/internal/output/emit.go
index 80206ebe9..8bf47229b 100644
--- a/internal/output/emit.go
+++ b/internal/output/emit.go
@@ -50,10 +50,11 @@ func wrapBlockError(alert *extcs.Alert) error {
// WriteAlertWarning writes a human-readable content-safety warning to w.
// Used by non-JSON output paths (pretty, table, csv) in warn mode.
-func WriteAlertWarning(w io.Writer, alert *extcs.Alert) {
+func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
if alert == nil {
- return
+ return nil
}
- fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
+ _, err := fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
alert.Provider, strings.Join(alert.MatchedRules, ", "))
+ return err
}
diff --git a/internal/output/emitter.go b/internal/output/emitter.go
new file mode 100644
index 000000000..417919723
--- /dev/null
+++ b/internal/output/emitter.go
@@ -0,0 +1,336 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package output
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "maps"
+
+ "github.com/larksuite/cli/errs"
+)
+
+// NoticeProvider supplies the notice attached to a structured envelope.
+// The provider is captured by an Emitter so emission never reads the global
+// PendingNotice hook implicitly.
+type NoticeProvider func() map[string]interface{}
+
+// PrettyRenderer writes the human-readable representation of one result.
+// colorEnabled is the terminal capability captured when the Emitter is built.
+type PrettyRenderer func(w io.Writer, colorEnabled bool) error
+
+// EmitterConfig contains command-scoped dependencies. A command constructs one
+// Emitter and reuses it for its success result or streamed pages.
+type EmitterConfig struct {
+ Out io.Writer
+ ErrOut io.Writer
+ CommandPath string
+ Identity string
+ ColorEnabled bool
+ NoticeProvider NoticeProvider
+}
+
+// EmitOptions describes one result's wire representation.
+//
+// The format contract is explicit: JSON (including the empty default) uses an
+// Envelope; pretty, table, csv, and ndjson render naked business data. JQ takes
+// precedence over Format and filters the JSON Envelope. Raw affects only JSON
+// envelope encoding and jq's complex-value encoding.
+//
+// JQSafetyWarning preserves the legacy difference between RuntimeContext.emit
+// (false) and WriteSuccessEnvelope (true) until their callers are migrated.
+type EmitOptions struct {
+ Raw bool
+ Meta *Meta
+ Format string
+ JQ string
+ DryRun bool
+ Pretty PrettyRenderer
+ JQSafetyWarning bool
+}
+
+// StreamOptions describes one streamed page's wire representation. Streaming
+// carries page items directly, so it deliberately exposes only the fields that
+// affect a single page: the format and, for pretty, its renderer. It has no
+// OK/Meta/DryRun/JQ — an ok:false envelope, metadata, dry-run, and jq all need
+// the aggregated result, which the caller's pagination layer owns before it
+// streams pages.
+type StreamOptions struct {
+ Format string
+ Pretty PrettyRenderer
+}
+
+// Emitter owns all command-scoped output dependencies and pagination state.
+// It deliberately has no dependency on client or cmdutil.
+type Emitter struct {
+ out io.Writer
+ errOut io.Writer
+ commandPath string
+ identity string
+ colorEnabled bool
+ noticeProvider NoticeProvider
+
+ streamFormat string
+ streamFormatter *PaginatedFormatter
+}
+
+// NewEmitter constructs a command-scoped output emitter.
+func NewEmitter(config EmitterConfig) *Emitter {
+ errOut := config.ErrOut
+ if errOut == nil {
+ errOut = io.Discard
+ }
+ return &Emitter{
+ out: config.Out,
+ errOut: errOut,
+ commandPath: config.CommandPath,
+ identity: config.Identity,
+ colorEnabled: config.ColorEnabled,
+ noticeProvider: config.NoticeProvider,
+ }
+}
+
+// Success scans and emits one command result by composing the package's leaf
+// primitives. JSON and jq use the standard envelope; pretty, table, csv, and
+// ndjson render the business value directly.
+func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
+ if err := e.requireOutput(); err != nil {
+ return err
+ }
+
+ if opts.JQ != "" {
+ return e.emitEnvelope(data, true, opts)
+ }
+
+ switch opts.Format {
+ case "", "json":
+ return e.emitEnvelope(data, true, opts)
+ case "pretty":
+ return e.emitPretty(data, opts)
+ default:
+ return e.emitFormatted(data, opts.Format)
+ }
+}
+
+// PartialFailure emits a multi-status result whose envelope honestly reports
+// ok:false. It is the typed counterpart to Success for batch operations where
+// some items failed but the per-item outcomes are the primary stdout output.
+// Like the legacy OutPartialFailure it produces only the JSON/jq envelope; the
+// caller owns the non-zero exit signal, keeping the Emitter free of exit
+// semantics.
+func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
+ if err := e.requireOutput(); err != nil {
+ return err
+ }
+ return e.emitEnvelope(data, false, opts)
+}
+
+// StreamPage scans and emits one page while retaining table/csv columns from
+// the first page. Streamed output carries page items directly, so it takes a
+// StreamOptions (format + optional pretty renderer) rather than the full
+// EmitOptions: ok/meta/dry-run/jq all need the aggregated result and are the
+// caller's pagination-layer responsibility, not a per-page concern. Excluding
+// jq from the type makes "jq requires aggregated output" a compile-time fact
+// instead of a runtime rejection.
+func (e *Emitter) StreamPage(data interface{}, opts StreamOptions) error {
+ if err := e.requireOutput(); err != nil {
+ return err
+ }
+
+ scanResult := ScanForSafety(e.commandPath, data, e.errOut)
+ if scanResult.Blocked {
+ return scanResult.BlockErr
+ }
+ if scanResult.Alert != nil {
+ if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
+ return wrapOutputError("write", err)
+ }
+ }
+
+ if opts.Format == "pretty" {
+ if opts.Pretty == nil {
+ return errs.NewInternalError(errs.SubtypeUnknown,
+ "pretty output requires a renderer")
+ }
+ return e.emit(func(w io.Writer) error {
+ return opts.Pretty(w, e.colorEnabled)
+ })
+ }
+
+ format, known := ParseFormat(opts.Format)
+ if !known && e.streamFormatter == nil && e.errOut != nil {
+ fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", opts.Format)
+ }
+ if e.streamFormatter == nil {
+ e.streamFormat = opts.Format
+ e.streamFormatter = NewPaginatedFormatter(nil, format)
+ } else if opts.Format != e.streamFormat {
+ return errs.NewInternalError(errs.SubtypeUnknown,
+ "stream output format changed from %q to %q", e.streamFormat, opts.Format)
+ }
+
+ return e.emit(func(w io.Writer) error {
+ e.streamFormatter.W = w
+ return e.streamFormatter.WritePage(data)
+ })
+}
+
+func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) error {
+ scanResult := ScanForSafety(e.commandPath, data, e.errOut)
+ if scanResult.Blocked {
+ return scanResult.BlockErr
+ }
+
+ env := Envelope{
+ OK: ok,
+ Identity: e.identity,
+ DryRun: opts.DryRun,
+ Data: data,
+ Meta: opts.Meta,
+ Notice: e.notice(),
+ }
+ if scanResult.Alert != nil {
+ env.ContentSafetyAlert = scanResult.Alert
+ }
+
+ if opts.JQ != "" {
+ if scanResult.Alert != nil && opts.JQSafetyWarning {
+ if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
+ return wrapOutputError("write", err)
+ }
+ }
+ // Buffer the jq output manually so jq's own typed error (a validation
+ // error for a bad expression, an api error for a runtime failure) is
+ // returned unchanged; only a genuine stdout write failure is wrapped as
+ // an internal output error.
+ var buf bytes.Buffer
+ var jqErr error
+ if opts.Raw {
+ jqErr = JqFilterRaw(&buf, env, opts.JQ)
+ } else {
+ jqErr = JqFilter(&buf, env, opts.JQ)
+ }
+ if jqErr != nil {
+ return jqErr
+ }
+ if _, err := io.Copy(e.out, &buf); err != nil {
+ return wrapOutputError("write", err)
+ }
+ return nil
+ }
+
+ return e.emit(func(w io.Writer) error {
+ if opts.Raw {
+ enc := json.NewEncoder(w)
+ enc.SetEscapeHTML(false)
+ enc.SetIndent("", " ")
+ return enc.Encode(env)
+ }
+ return WriteJSON(w, env)
+ })
+}
+
+func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
+ scanResult := ScanForSafety(e.commandPath, data, e.errOut)
+ if scanResult.Blocked {
+ return scanResult.BlockErr
+ }
+ if scanResult.Alert != nil {
+ if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
+ return wrapOutputError("write", err)
+ }
+ }
+ if opts.Pretty != nil {
+ return e.emit(func(w io.Writer) error {
+ return opts.Pretty(w, e.colorEnabled)
+ })
+ }
+
+ // RuntimeContext.outFormat falls back through Out/OutRaw when no pretty
+ // renderer is supplied. Keep that second scan visible in the leaf contract
+ // until production callers are migrated and the legacy behavior is removed.
+ return e.emitEnvelope(data, true, opts)
+}
+
+func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
+ scanResult := ScanForSafety(e.commandPath, data, e.errOut)
+ if scanResult.Blocked {
+ return scanResult.BlockErr
+ }
+ if scanResult.Alert != nil {
+ if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
+ return wrapOutputError("write", err)
+ }
+ }
+
+ format, known := ParseFormat(rawFormat)
+ if !known && e.errOut != nil {
+ fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", rawFormat)
+ }
+ if format == FormatJSON {
+ return e.printLegacyDataJSON(data)
+ }
+ return e.emit(func(w io.Writer) error {
+ return WriteFormatted(w, data, format)
+ })
+}
+
+type emitterDataMap map[string]interface{}
+
+// printLegacyDataJSON matches FormatValue's JSON branch while sourcing notice
+// data from this Emitter instead of PrintJson's global PendingNotice hook.
+func (e *Emitter) printLegacyDataJSON(data interface{}) error {
+ // Normalise structs / named maps to plain generic types first, exactly as
+ // FormatValue does, so a struct or named-map payload still matches the map
+ // case below and keeps its injected _notice on the unknown-format fallback.
+ data = toGeneric(data)
+ if m, ok := data.(map[string]interface{}); ok {
+ if _, isEnvelope := m["ok"]; isEnvelope {
+ if notice := e.notice(); notice != nil {
+ m = maps.Clone(m)
+ m["_notice"] = notice
+ }
+ }
+ // The named map retains identical JSON bytes while preventing PrintJson
+ // from consulting its legacy global notice hook a second time.
+ return e.emit(func(w io.Writer) error {
+ return WriteJSON(w, emitterDataMap(m))
+ })
+ }
+ return e.emit(func(w io.Writer) error {
+ return WriteJSON(w, data)
+ })
+}
+
+func (e *Emitter) emit(render func(io.Writer) error) error {
+ var buf bytes.Buffer
+ if err := render(&buf); err != nil {
+ return wrapOutputError("render", err)
+ }
+ if _, err := io.Copy(e.out, &buf); err != nil {
+ return wrapOutputError("write", err)
+ }
+ return nil
+}
+
+func wrapOutputError(op string, err error) error {
+ return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s command output", op).WithCause(err)
+}
+
+func (e *Emitter) notice() map[string]interface{} {
+ if e.noticeProvider == nil {
+ return nil
+ }
+ return e.noticeProvider()
+}
+
+func (e *Emitter) requireOutput() error {
+ if e == nil || e.out == nil {
+ return errs.NewInternalError(errs.SubtypeUnknown,
+ "success output writer is not configured")
+ }
+ return nil
+}
diff --git a/internal/output/emitter_contract_test.go b/internal/output/emitter_contract_test.go
new file mode 100644
index 000000000..bfb9ecbb5
--- /dev/null
+++ b/internal/output/emitter_contract_test.go
@@ -0,0 +1,350 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package output_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+ extcs "github.com/larksuite/cli/extension/contentsafety"
+ "github.com/larksuite/cli/internal/output"
+)
+
+type contractFailingWriter struct {
+ err error
+}
+
+func (w contractFailingWriter) Write([]byte) (int, error) {
+ return 0, w.err
+}
+
+type contractSafetyProvider struct {
+ alert *extcs.Alert
+}
+
+func (p *contractSafetyProvider) Name() string {
+ return "emitter-contract"
+}
+
+func (p *contractSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
+ return p.alert, nil
+}
+
+func TestEmitterSuccessWritesAllBytes(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ stdout := &bytes.Buffer{}
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: stdout,
+ ErrOut: io.Discard,
+ CommandPath: "lark-cli fixture +emit",
+ Identity: "bot",
+ })
+ data := map[string]interface{}{"id": "1"}
+
+ err := emitter.Success(data, output.EmitOptions{Format: "json"})
+ if err != nil {
+ t.Fatalf("Emitter.Success() error = %v", err)
+ }
+ want, marshalErr := json.MarshalIndent(output.Envelope{OK: true, Identity: "bot", Data: data}, "", " ")
+ if marshalErr != nil {
+ t.Fatalf("marshal expected envelope: %v", marshalErr)
+ }
+ want = append(want, '\n')
+ if !bytes.Equal(stdout.Bytes(), want) {
+ t.Fatalf("stdout bytes = %q, want %q", stdout.Bytes(), want)
+ }
+}
+
+func TestEmitterMarshalFailureReturnsTypedErrorWithoutOutput(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ stdout := &bytes.Buffer{}
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: stdout,
+ ErrOut: io.Discard,
+ CommandPath: "lark-cli fixture +emit",
+ })
+
+ err := emitter.Success(map[string]interface{}{"unsupported": func() {}}, output.EmitOptions{Format: "json"})
+ if err == nil {
+ t.Fatal("Emitter.Success() error = nil, want marshal failure")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryInternal {
+ t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
+ }
+ var unsupported *json.UnsupportedTypeError
+ if !errors.As(err, &unsupported) {
+ t.Fatalf("Emitter.Success() error = %v, want json.UnsupportedTypeError cause", err)
+ }
+ if stdout.Len() != 0 {
+ t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
+ }
+}
+
+func TestEmitterWriterFailurePreservesCause(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ sentinel := errors.New("write failed")
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: contractFailingWriter{err: sentinel},
+ ErrOut: io.Discard,
+ CommandPath: "lark-cli fixture +emit",
+ })
+
+ err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"})
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryInternal {
+ t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
+ }
+}
+
+func TestEmitterPrettyRendererFailurePreservesCause(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ sentinel := errors.New("pretty render failed")
+ stdout := &bytes.Buffer{}
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: stdout,
+ ErrOut: io.Discard,
+ CommandPath: "lark-cli fixture +emit",
+ })
+
+ err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
+ Format: "pretty",
+ Pretty: func(io.Writer, bool) error {
+ return sentinel
+ },
+ })
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("Emitter.Success() error = %v, want preserved renderer cause", err)
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryInternal {
+ t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
+ }
+ if stdout.Len() != 0 {
+ t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
+ }
+}
+
+func TestEmitterAlertWarningFailurePreservesCause(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
+ extcs.Register(&contractSafetyProvider{alert: &extcs.Alert{
+ Provider: "emitter-contract",
+ MatchedRules: []string{"fixture-rule"},
+ }})
+ t.Cleanup(func() { extcs.Register(nil) })
+ sentinel := errors.New("warning write failed")
+ stdout := &bytes.Buffer{}
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: stdout,
+ ErrOut: contractFailingWriter{err: sentinel},
+ CommandPath: "lark-cli fixture +emit",
+ })
+
+ err := emitter.Success([]interface{}{map[string]interface{}{"id": "1"}}, output.EmitOptions{Format: "table"})
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("Emitter.Success() error = %v, want preserved warning writer cause", err)
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryInternal {
+ t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
+ }
+ if stdout.Len() != 0 {
+ t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
+ }
+}
+
+func TestNewEmitterDefaultsNilErrOutToDiscard(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
+ extcs.Register(&contractSafetyProvider{alert: &extcs.Alert{
+ Provider: "emitter-contract",
+ MatchedRules: []string{"fixture-rule"},
+ }})
+ t.Cleanup(func() { extcs.Register(nil) })
+ stdout := &bytes.Buffer{}
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: stdout,
+ CommandPath: "lark-cli fixture +emit",
+ })
+
+ if err := emitter.Success([]interface{}{map[string]interface{}{"id": "1"}}, output.EmitOptions{Format: "table"}); err != nil {
+ t.Fatalf("Emitter.Success() error = %v", err)
+ }
+ if stdout.Len() == 0 {
+ t.Fatal("Emitter.Success() stdout is empty")
+ }
+}
+
+func TestEmitterDoesNotMutateCallerMap(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ data := map[string]interface{}{"ok": true, "value": "fixture"}
+ want := map[string]interface{}{"ok": true, "value": "fixture"}
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: &bytes.Buffer{},
+ ErrOut: io.Discard,
+ CommandPath: "lark-cli fixture +emit",
+ NoticeProvider: func() map[string]interface{} {
+ return map[string]interface{}{"update": "available"}
+ },
+ })
+
+ if err := emitter.Success(data, output.EmitOptions{Format: "yaml"}); err != nil {
+ t.Fatalf("Emitter.Success() error = %v", err)
+ }
+ if !reflect.DeepEqual(data, want) {
+ t.Fatalf("caller map = %#v, want unchanged %#v", data, want)
+ }
+}
+
+func TestEmitterDoesNotOverwriteCallerNotice(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ existing := map[string]interface{}{"source": "caller"}
+ data := map[string]interface{}{"ok": true, "_notice": existing}
+ stdout := &bytes.Buffer{}
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: stdout,
+ ErrOut: io.Discard,
+ CommandPath: "lark-cli fixture +emit",
+ NoticeProvider: func() map[string]interface{} {
+ return map[string]interface{}{"source": "provider"}
+ },
+ })
+
+ if err := emitter.Success(data, output.EmitOptions{Format: "yaml"}); err != nil {
+ t.Fatalf("Emitter.Success() error = %v", err)
+ }
+ if got := data["_notice"]; !reflect.DeepEqual(got, existing) {
+ t.Fatalf("caller _notice = %#v, want unchanged %#v", got, existing)
+ }
+ var emitted map[string]interface{}
+ if err := json.Unmarshal(stdout.Bytes(), &emitted); err != nil {
+ t.Fatalf("decode stdout: %v", err)
+ }
+ if got := emitted["_notice"]; !reflect.DeepEqual(got, map[string]interface{}{"source": "provider"}) {
+ t.Fatalf("emitted _notice = %#v, want provider notice", got)
+ }
+}
+
+func TestEmitterReadsNoticeProviderAtMostOncePerEmission(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ calls := 0
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: &bytes.Buffer{},
+ ErrOut: io.Discard,
+ CommandPath: "lark-cli fixture +emit",
+ NoticeProvider: func() map[string]interface{} {
+ calls++
+ return map[string]interface{}{"source": "provider"}
+ },
+ })
+
+ if err := emitter.Success(map[string]interface{}{"ok": true}, output.EmitOptions{Format: "yaml"}); err != nil {
+ t.Fatalf("Emitter.Success() error = %v", err)
+ }
+ if calls != 1 {
+ t.Fatalf("notice provider calls = %d, want 1", calls)
+ }
+}
+
+func TestEmitterRawJSONPropagatesWriteError(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ sentinel := errors.New("write failed")
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: contractFailingWriter{err: sentinel},
+ ErrOut: io.Discard,
+ CommandPath: "lark-cli fixture +emit",
+ })
+ err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
+ Raw: true, Format: "json",
+ })
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryInternal {
+ t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
+ }
+}
+
+func TestEmitterInvalidJQReturnsErrorWithoutStderr(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ stderr := &bytes.Buffer{}
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: &bytes.Buffer{},
+ ErrOut: stderr,
+ CommandPath: "lark-cli fixture +emit",
+ })
+ err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
+ Format: "json",
+ JQ: "this is not valid jq (((",
+ })
+ if err == nil {
+ t.Fatal("Success() with invalid jq = nil, want error")
+ }
+ if stderr.Len() != 0 {
+ t.Fatalf("Success() with invalid jq wrote stderr %q, want empty", stderr.String())
+ }
+}
+
+func TestEmitterJQRuntimeErrorPreservesTypedError(t *testing.T) {
+ // A valid expression that fails at runtime must surface jq's own typed error
+ // (an api error), not a wrapped internal output error, and must emit no
+ // partial stdout.
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ stdout := &bytes.Buffer{}
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: stdout,
+ ErrOut: io.Discard,
+ CommandPath: "lark-cli fixture +emit",
+ })
+ err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
+ Format: "json",
+ JQ: `error("boom")`,
+ })
+ if err == nil {
+ t.Fatal("Success() with a runtime jq error = nil, want error")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category == errs.CategoryInternal {
+ t.Fatalf("Success() jq runtime error problem = %#v, %v; want jq's own typed error, not internal", problem, ok)
+ }
+ if !strings.Contains(err.Error(), "jq error") {
+ t.Fatalf("Success() jq runtime error = %v, want jq's own error message preserved", err)
+ }
+ if stdout.Len() != 0 {
+ t.Fatalf("Success() jq runtime error wrote stdout %q, want empty", stdout.String())
+ }
+}
+
+func TestEmitterUnknownFormatStructKeepsNotice(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ type payload struct {
+ OK bool `json:"ok"`
+ Value string `json:"value"`
+ }
+ stdout := &bytes.Buffer{}
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: stdout,
+ ErrOut: io.Discard,
+ CommandPath: "lark-cli fixture +emit",
+ NoticeProvider: func() map[string]interface{} {
+ return map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}}
+ },
+ })
+ if err := emitter.Success(payload{OK: true, Value: "fixture"}, output.EmitOptions{Format: "yaml"}); err != nil {
+ t.Fatalf("Success() error = %v", err)
+ }
+ if !strings.Contains(stdout.String(), "_notice") {
+ t.Fatalf("struct payload on unknown-format fallback dropped _notice:\n%s", stdout.String())
+ }
+}
diff --git a/internal/output/emitter_legacy_compat_test.go b/internal/output/emitter_legacy_compat_test.go
new file mode 100644
index 000000000..6063a898c
--- /dev/null
+++ b/internal/output/emitter_legacy_compat_test.go
@@ -0,0 +1,827 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+// Legacy oracle fixtures are frozen at base SHA 4a56748bfa941ff0ee0bfec92e65acac427732b0.
+// Golden regeneration is allowed only from that base, never from the current system under test.
+
+package output_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+
+ "github.com/larksuite/cli/errs"
+ extcs "github.com/larksuite/cli/extension/contentsafety"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/internal/output"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+type emitterCapture struct {
+ stdout string
+ stderr string
+ err error
+}
+
+type emitterSafetyProvider struct {
+ alert *extcs.Alert
+ err error
+}
+
+func (p *emitterSafetyProvider) Name() string { return "emitter-oracle" }
+
+func (p *emitterSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
+ return p.alert, p.err
+}
+
+const (
+ runtimeContextLegacyGoldenPath = "testdata/runtime_context_legacy.golden.json"
+ writeSuccessEnvelopeLegacyGoldenPath = "testdata/write_success_envelope_legacy.golden.json"
+)
+
+type runtimeContextOracleCase struct {
+ name string
+ data func() interface{}
+ raw bool
+ ok bool
+ meta *output.Meta
+ jq string
+ format string
+ useFormat bool
+ pretty bool
+ notice map[string]interface{}
+ safetyMode string
+ safetyAlert *extcs.Alert
+ safetyErr error
+}
+
+type runtimeContextLegacyGolden struct {
+ Cases map[string]emitterCaptureGolden `json:"cases"`
+}
+
+type writeSuccessEnvelopeOracleCase struct {
+ name string
+ data func() interface{}
+ dryRun bool
+ jq string
+ notice map[string]interface{}
+ safetyMode string
+ safetyAlert *extcs.Alert
+}
+
+type writeSuccessEnvelopeLegacyGolden struct {
+ Cases map[string]emitterCaptureGolden `json:"cases"`
+}
+
+type emitterCaptureGolden struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ Error *emitterErrorGolden `json:"error,omitempty"`
+}
+
+type emitterErrorGolden struct {
+ GoType string `json:"go_type"`
+ JSON json.RawMessage `json:"json"`
+ Message string `json:"message"`
+ ExitCode int `json:"exit_code"`
+}
+
+func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
+ previousNotice := output.PendingNotice
+ t.Cleanup(func() {
+ output.PendingNotice = previousNotice
+ extcs.Register(nil)
+ })
+
+ cases := []runtimeContextOracleCase{
+ {
+ name: "json_object",
+ data: func() interface{} {
+ return map[string]interface{}{"id": "1", "enabled": true}
+ },
+ ok: true,
+ },
+ {
+ name: "raw_json_preserves_html",
+ data: func() interface{} {
+ return map[string]interface{}{"html": "
a&b
"}
+ },
+ raw: true,
+ ok: true,
+ },
+ {
+ name: "format_raw_json_preserves_html",
+ data: func() interface{} {
+ return map[string]interface{}{"html": "a&b
"}
+ },
+ raw: true,
+ ok: true,
+ format: "json",
+ useFormat: true,
+ },
+ {
+ name: "partial_failure_ok_false",
+ data: func() interface{} {
+ return map[string]interface{}{"succeeded": 1, "failed": 1}
+ },
+ ok: false,
+ },
+ {
+ name: "metadata",
+ data: func() interface{} {
+ return []interface{}{map[string]interface{}{"id": "1"}}
+ },
+ ok: true,
+ meta: &output.Meta{Count: 1, Rollback: "lark-cli fixture rollback"},
+ },
+ {
+ name: "jq_scalar",
+ data: func() interface{} {
+ return map[string]interface{}{"name": "Alice", "age": 30}
+ },
+ ok: true,
+ jq: ".data.name",
+ },
+ {
+ name: "raw_jq_complex",
+ data: func() interface{} {
+ return map[string]interface{}{"document": map[string]interface{}{"html": "a&b
"}}
+ },
+ raw: true,
+ ok: true,
+ jq: ".data.document",
+ },
+ {
+ name: "jq_invalid_expression",
+ data: func() interface{} {
+ return map[string]interface{}{"id": "1"}
+ },
+ ok: false,
+ jq: "invalid[",
+ },
+ {
+ name: "notice",
+ data: func() interface{} {
+ return map[string]interface{}{"id": "1"}
+ },
+ ok: true,
+ notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}},
+ },
+ {
+ name: "pretty",
+ data: func() interface{} {
+ return map[string]interface{}{"name": "Alice"}
+ },
+ ok: true,
+ format: "pretty",
+ useFormat: true,
+ pretty: true,
+ },
+ {
+ name: "pretty_without_renderer",
+ data: func() interface{} {
+ return map[string]interface{}{"name": "Alice"}
+ },
+ ok: true,
+ format: "pretty",
+ useFormat: true,
+ },
+ {
+ name: "ndjson",
+ data: func() interface{} {
+ return map[string]interface{}{"items": []interface{}{
+ map[string]interface{}{"id": "1"},
+ map[string]interface{}{"id": "2"},
+ }}
+ },
+ ok: true,
+ format: "ndjson",
+ useFormat: true,
+ },
+ {
+ name: "table_with_safety_warning",
+ data: func() interface{} {
+ return []interface{}{map[string]interface{}{"id": "1", "name": "Alice"}}
+ },
+ ok: true,
+ format: "table",
+ useFormat: true,
+ safetyMode: "warn",
+ safetyAlert: &extcs.Alert{
+ Provider: "emitter-oracle",
+ MatchedRules: []string{"fixture-rule"},
+ },
+ },
+ {
+ name: "csv",
+ data: func() interface{} {
+ return []interface{}{
+ map[string]interface{}{"id": "1", "name": "Alice"},
+ map[string]interface{}{"id": "2", "name": "Bob"},
+ }
+ },
+ ok: true,
+ format: "csv",
+ useFormat: true,
+ },
+ {
+ name: "jq_safety_alert_without_stderr_warning",
+ data: func() interface{} {
+ return map[string]interface{}{"id": "1"}
+ },
+ ok: true,
+ jq: ".data.id",
+ safetyMode: "warn",
+ safetyAlert: &extcs.Alert{
+ Provider: "emitter-oracle",
+ MatchedRules: []string{"fixture-rule"},
+ },
+ },
+ {
+ name: "scanner_error_fails_open",
+ data: func() interface{} {
+ return map[string]interface{}{"id": "1"}
+ },
+ ok: true,
+ safetyMode: "warn",
+ safetyErr: errors.New("scanner unavailable"),
+ },
+ {
+ name: "scanner_block",
+ data: func() interface{} {
+ return map[string]interface{}{"id": "blocked"}
+ },
+ ok: false,
+ safetyMode: "block",
+ safetyAlert: &extcs.Alert{
+ Provider: "emitter-oracle",
+ MatchedRules: []string{"fixture-rule"},
+ },
+ },
+ {
+ name: "unknown_format_data_envelope_notice",
+ data: func() interface{} {
+ return map[string]interface{}{"ok": true, "value": "fixture"}
+ },
+ ok: true,
+ format: "yaml",
+ useFormat: true,
+ notice: map[string]interface{}{"skills": map[string]interface{}{"current": "1.0.0"}},
+ },
+ }
+
+ golden := loadRuntimeContextLegacyGolden(t)
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ mode := tc.safetyMode
+ if mode == "" {
+ mode = "off"
+ }
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
+ extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert, err: tc.safetyErr})
+ t.Cleanup(func() { extcs.Register(nil) })
+
+ notice := tc.notice
+ output.PendingNotice = func() map[string]interface{} { return notice }
+
+ want, ok := golden.Cases[tc.name]
+ if !ok {
+ t.Fatalf("frozen golden case %q is missing", tc.name)
+ }
+
+ opts := runtimeOracleOptions{
+ raw: tc.raw,
+ ok: tc.ok,
+ meta: tc.meta,
+ jq: tc.jq,
+ format: tc.format,
+ useFormat: tc.useFormat,
+ pretty: tc.pretty,
+ }
+ current := runEmitterWithRuntimeContextContract(tc.data(), output.EmitterConfig{
+ CommandPath: "lark-cli fixture +emit",
+ Identity: "bot",
+ NoticeProvider: func() map[string]interface{} { return notice },
+ }, tc.ok, output.EmitOptions{
+ Raw: tc.raw,
+ Meta: tc.meta,
+ Format: tc.format,
+ JQ: tc.jq,
+ Pretty: emitterPrettyRenderer(tc.pretty),
+ })
+
+ assertEmitterGolden(t, want, current)
+
+ integrated := runRuntimeContextOracle(t, tc.data(), opts)
+ assertEmitterGolden(t, want, integrated)
+ if tc.safetyMode == "block" {
+ var safetyErr *errs.ContentSafetyError
+ if !errors.As(current.err, &safetyErr) {
+ t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", current.err)
+ }
+ }
+ })
+ }
+
+ if len(golden.Cases) != len(cases) {
+ t.Fatalf("golden case count = %d, want %d", len(golden.Cases), len(cases))
+ }
+
+ jqFailure := golden.Cases["jq_invalid_expression"]
+ if !strings.HasPrefix(jqFailure.Stderr, "error: ") || !strings.HasSuffix(jqFailure.Stderr, "\n") {
+ t.Fatalf("invalid jq golden stderr = %q, want error line ending in newline", jqFailure.Stderr)
+ }
+ if jqFailure.Error == nil || jqFailure.Error.ExitCode != output.ExitValidation {
+ t.Fatalf("invalid jq golden exit = %#v, want %d", jqFailure.Error, output.ExitValidation)
+ }
+}
+
+func loadRuntimeContextLegacyGolden(t *testing.T) runtimeContextLegacyGolden {
+ t.Helper()
+ contents, err := os.ReadFile(runtimeContextLegacyGoldenPath)
+ if err != nil {
+ t.Fatalf("read RuntimeContext legacy golden: %v", err)
+ }
+ var golden runtimeContextLegacyGolden
+ if err := json.Unmarshal(contents, &golden); err != nil {
+ t.Fatalf("decode RuntimeContext legacy golden: %v", err)
+ }
+ return golden
+}
+
+func captureEmitterGolden(t *testing.T, capture emitterCapture) emitterCaptureGolden {
+ t.Helper()
+ golden := emitterCaptureGolden{Stdout: capture.stdout, Stderr: capture.stderr}
+ if capture.err == nil {
+ return golden
+ }
+ errorJSON, err := json.Marshal(capture.err)
+ if err != nil {
+ t.Fatalf("marshal captured error %T: %v", capture.err, err)
+ }
+ golden.Error = &emitterErrorGolden{
+ GoType: fmt.Sprintf("%T", capture.err),
+ JSON: errorJSON,
+ Message: capture.err.Error(),
+ ExitCode: output.ExitCodeOf(capture.err),
+ }
+ return golden
+}
+
+type runtimeOracleOptions struct {
+ raw bool
+ ok bool
+ meta *output.Meta
+ jq string
+ format string
+ useFormat bool
+ pretty bool
+}
+
+func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
+ t.Helper()
+ stdout := &bytes.Buffer{}
+ stderr := &bytes.Buffer{}
+ parent := &cobra.Command{Use: "lark-cli"}
+ cmd := &cobra.Command{Use: "fixture"}
+ leaf := &cobra.Command{Use: "+emit"}
+ parent.AddCommand(cmd)
+ cmd.AddCommand(leaf)
+
+ factory := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}}
+ runtime := common.TestNewRuntimeContextForAPI(
+ context.Background(), leaf, &core.CliConfig{Brand: core.BrandFeishu}, factory, core.AsBot,
+ )
+ runtime.Format = opts.format
+ runtime.JqExpr = opts.jq
+
+ pretty := func(w io.Writer) {
+ fmt.Fprintln(w, "pretty:fixture")
+ }
+ if !opts.pretty {
+ pretty = nil
+ }
+
+ var err error
+ switch {
+ case opts.useFormat && opts.raw:
+ runtime.OutFormatRaw(data, opts.meta, pretty)
+ case opts.useFormat:
+ runtime.OutFormat(data, opts.meta, pretty)
+ case !opts.ok:
+ err = runtime.OutPartialFailure(data, opts.meta)
+ case opts.raw:
+ runtime.OutRaw(data, opts.meta)
+ default:
+ runtime.Out(data, opts.meta)
+ }
+
+ return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
+}
+
+func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
+ stdout := &bytes.Buffer{}
+ stderr := &bytes.Buffer{}
+ config.Out = stdout
+ config.ErrOut = stderr
+ emitter := output.NewEmitter(config)
+ var err error
+ if ok {
+ err = emitter.Success(data, opts)
+ } else {
+ err = emitter.PartialFailure(data, opts)
+ }
+ return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
+}
+
+func runEmitterWithRuntimeContextContract(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
+ capture := runEmitterSuccess(data, config, ok, opts)
+ if capture.err != nil {
+ var safetyErr *errs.ContentSafetyError
+ if errors.As(capture.err, &safetyErr) {
+ return capture
+ }
+ if opts.JQ != "" {
+ capture.stderr += fmt.Sprintf("error: %v\n", capture.err)
+ return capture
+ }
+ capture.err = nil
+ }
+ if !ok {
+ capture.err = output.PartialFailure(output.ExitAPI)
+ }
+ return capture
+}
+
+func emitterPrettyRenderer(enabled bool) output.PrettyRenderer {
+ if !enabled {
+ return nil
+ }
+ return func(w io.Writer, _ bool) error {
+ _, err := fmt.Fprintln(w, "pretty:fixture")
+ return err
+ }
+}
+
+func TestEmitterMatchesWriteSuccessEnvelopeLegacyOracle(t *testing.T) {
+ previousNotice := output.PendingNotice
+ t.Cleanup(func() {
+ output.PendingNotice = previousNotice
+ extcs.Register(nil)
+ })
+
+ cases := []writeSuccessEnvelopeOracleCase{
+ {
+ name: "json",
+ data: func() interface{} { return map[string]interface{}{"id": "1"} },
+ },
+ {
+ name: "dry_run",
+ data: func() interface{} { return map[string]interface{}{"api": []interface{}{}} },
+ dryRun: true,
+ },
+ {
+ name: "jq",
+ data: func() interface{} { return map[string]interface{}{"id": "1"} },
+ jq: ".data.id",
+ },
+ {
+ name: "notice",
+ data: func() interface{} { return map[string]interface{}{"id": "1"} },
+ notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}},
+ },
+ {
+ name: "jq_safety_warning",
+ data: func() interface{} { return map[string]interface{}{"id": "1"} },
+ jq: ".data.id",
+ safetyMode: "warn",
+ safetyAlert: &extcs.Alert{
+ Provider: "emitter-oracle",
+ MatchedRules: []string{"fixture-rule"},
+ },
+ },
+ {
+ name: "scanner_block",
+ data: func() interface{} { return map[string]interface{}{"id": "blocked"} },
+ safetyMode: "block",
+ safetyAlert: &extcs.Alert{
+ Provider: "emitter-oracle",
+ MatchedRules: []string{"fixture-rule"},
+ },
+ },
+ }
+ golden := loadWriteSuccessEnvelopeLegacyGolden(t)
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ mode := tc.safetyMode
+ if mode == "" {
+ mode = "off"
+ }
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
+ extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert})
+ t.Cleanup(func() { extcs.Register(nil) })
+ notice := tc.notice
+ output.PendingNotice = func() map[string]interface{} { return notice }
+
+ want, ok := golden.Cases[tc.name]
+ if !ok {
+ t.Fatalf("frozen golden case %q is missing", tc.name)
+ }
+
+ current := runEmitterSuccess(tc.data(), output.EmitterConfig{
+ CommandPath: "lark-cli fixture +emit",
+ Identity: "bot",
+ NoticeProvider: func() map[string]interface{} { return notice },
+ }, true, output.EmitOptions{
+ Format: "",
+ Raw: false,
+ JQ: tc.jq,
+ DryRun: tc.dryRun,
+ JQSafetyWarning: true,
+ })
+ assertEmitterGolden(t, want, current)
+
+ integrated := runWriteSuccessEnvelopeOracle(tc.data(), tc.dryRun, tc.jq)
+ assertEmitterGolden(t, want, integrated)
+ })
+ }
+
+ if len(golden.Cases) != len(cases) {
+ t.Fatalf("golden case count = %d, want %d", len(golden.Cases), len(cases))
+ }
+}
+
+func loadWriteSuccessEnvelopeLegacyGolden(t *testing.T) writeSuccessEnvelopeLegacyGolden {
+ t.Helper()
+ contents, err := os.ReadFile(writeSuccessEnvelopeLegacyGoldenPath)
+ if err != nil {
+ t.Fatalf("read WriteSuccessEnvelope legacy golden: %v", err)
+ }
+ var golden writeSuccessEnvelopeLegacyGolden
+ if err := json.Unmarshal(contents, &golden); err != nil {
+ t.Fatalf("decode WriteSuccessEnvelope legacy golden: %v", err)
+ }
+ return golden
+}
+
+func runWriteSuccessEnvelopeOracle(data interface{}, dryRun bool, jq string) emitterCapture {
+ stdout := &bytes.Buffer{}
+ stderr := &bytes.Buffer{}
+ err := output.WriteSuccessEnvelope(data, output.SuccessEnvelopeOptions{
+ CommandPath: "lark-cli fixture +emit",
+ Identity: "bot",
+ DryRun: dryRun,
+ JqExpr: jq,
+ Out: stdout,
+ ErrOut: stderr,
+ })
+ return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
+}
+
+func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
+ t.Cleanup(func() { extcs.Register(nil) })
+
+ type oracleCase struct {
+ name string
+ format output.Format
+ safetyMode string
+ safetyAlert *extcs.Alert
+ }
+ cases := []oracleCase{
+ {name: "ndjson", format: output.FormatNDJSON},
+ {name: "table", format: output.FormatTable},
+ {name: "csv", format: output.FormatCSV},
+ {
+ name: "warn",
+ format: output.FormatNDJSON,
+ safetyMode: "warn",
+ safetyAlert: &extcs.Alert{
+ Provider: "emitter-oracle",
+ MatchedRules: []string{"fixture-rule"},
+ },
+ },
+ {
+ name: "block",
+ format: output.FormatTable,
+ safetyMode: "block",
+ safetyAlert: &extcs.Alert{
+ Provider: "emitter-oracle",
+ MatchedRules: []string{"fixture-rule"},
+ },
+ },
+ }
+
+ pages := []interface{}{
+ []interface{}{map[string]interface{}{"id": "1", "name": "Alice"}},
+ []interface{}{map[string]interface{}{"id": "2", "name": "Bob", "ignored": true}},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ mode := tc.safetyMode
+ if mode == "" {
+ mode = "off"
+ }
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
+ extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert})
+ t.Cleanup(func() { extcs.Register(nil) })
+
+ legacy := runPaginationOracle(pages, tc.format)
+ current := runEmitterStreamPages(pages, tc.format.String())
+
+ assertEmitterBytes(t, legacy, current)
+ assertEquivalentError(t, legacy.err, current.err)
+ })
+ }
+}
+
+func runPaginationOracle(pages []interface{}, format output.Format) emitterCapture {
+ stdout := &bytes.Buffer{}
+ stderr := &bytes.Buffer{}
+ formatter := output.NewPaginatedFormatter(stdout, format)
+ var emitErr error
+ for _, page := range pages {
+ scanResult := output.ScanForSafety("lark-cli fixture +emit", page, stderr)
+ if scanResult.Blocked {
+ emitErr = scanResult.BlockErr
+ break
+ }
+ if scanResult.Alert != nil {
+ output.WriteAlertWarning(stderr, scanResult.Alert)
+ }
+ formatter.FormatPage(page)
+ }
+ return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
+}
+
+func runEmitterStreamPages(pages []interface{}, format string) emitterCapture {
+ stdout := &bytes.Buffer{}
+ stderr := &bytes.Buffer{}
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: stdout,
+ ErrOut: stderr,
+ CommandPath: "lark-cli fixture +emit",
+ Identity: "bot",
+ })
+ var emitErr error
+ for _, page := range pages {
+ if emitErr = emitter.StreamPage(page, output.StreamOptions{Format: format}); emitErr != nil {
+ break
+ }
+ }
+ return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
+}
+
+func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
+ previousNotice := output.PendingNotice
+ output.PendingNotice = func() map[string]interface{} {
+ return map[string]interface{}{"source": "global"}
+ }
+ t.Cleanup(func() { output.PendingNotice = previousNotice })
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+
+ stdout := &bytes.Buffer{}
+ stderr := &bytes.Buffer{}
+ colorSeen := false
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: stdout,
+ ErrOut: stderr,
+ CommandPath: "lark-cli fixture +emit",
+ Identity: "bot",
+ ColorEnabled: true,
+ NoticeProvider: func() map[string]interface{} {
+ return map[string]interface{}{"source": "captured"}
+ },
+ })
+ if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"}); err != nil {
+ t.Fatalf("Emitter.Success() error = %v", err)
+ }
+ if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
+ t.Fatalf("notice source was not captured by Emitter:\n%s", stdout.String())
+ }
+
+ stdout.Reset()
+ if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "pretty",
+ Pretty: func(w io.Writer, colorEnabled bool) error {
+ colorSeen = colorEnabled
+ _, err := fmt.Fprintln(w, "pretty")
+ return err
+ },
+ }); err != nil {
+ t.Fatalf("Emitter.Success(pretty) error = %v", err)
+ }
+ if !colorSeen {
+ t.Fatal("PrettyRenderer did not receive captured ColorEnabled value")
+ }
+
+ stdout.Reset()
+ if err := emitter.Success(map[string]interface{}{"ok": true, "id": "1"}, output.EmitOptions{Format: "yaml"}); err != nil {
+ t.Fatalf("Emitter.Success(unknown format) error = %v", err)
+ }
+ if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
+ t.Fatalf("legacy JSON fallback consulted global notice:\n%s", stdout.String())
+ }
+}
+
+type failingEmitterWriter struct {
+ err error
+}
+
+func (w failingEmitterWriter) Write([]byte) (int, error) { return 0, w.err }
+
+func TestEmitterPropagatesOutputError(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
+ sentinel := errors.New("write failed")
+ emitter := output.NewEmitter(output.EmitterConfig{
+ Out: failingEmitterWriter{err: sentinel},
+ ErrOut: io.Discard,
+ CommandPath: "lark-cli fixture +emit",
+ })
+ err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
+ Raw: true, Format: "json",
+ JQ: ".data",
+ })
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryInternal {
+ t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
+ }
+}
+
+func assertEmitterBytes(t *testing.T, legacy, current emitterCapture) {
+ t.Helper()
+ if legacy.stdout != current.stdout {
+ t.Fatalf("stdout byte mismatch\nlegacy (%d bytes):\n%q\nEmitter (%d bytes):\n%q",
+ len(legacy.stdout), legacy.stdout, len(current.stdout), current.stdout)
+ }
+ if legacy.stderr != current.stderr {
+ t.Fatalf("stderr byte mismatch\nlegacy (%d bytes):\n%q\nEmitter (%d bytes):\n%q",
+ len(legacy.stderr), legacy.stderr, len(current.stderr), current.stderr)
+ }
+}
+
+func assertEmitterGolden(t *testing.T, want emitterCaptureGolden, current emitterCapture) {
+ t.Helper()
+ if want.Stdout != current.stdout {
+ t.Fatalf("stdout byte mismatch\ngolden (%d bytes):\n%q\ncurrent (%d bytes):\n%q",
+ len(want.Stdout), want.Stdout, len(current.stdout), current.stdout)
+ }
+ if want.Stderr != current.stderr {
+ t.Fatalf("stderr byte mismatch\ngolden (%d bytes):\n%q\ncurrent (%d bytes):\n%q",
+ len(want.Stderr), want.Stderr, len(current.stderr), current.stderr)
+ }
+ got := captureEmitterGolden(t, current)
+ if (want.Error == nil) != (got.Error == nil) {
+ t.Fatalf("error presence mismatch: golden=%#v current=%#v", want.Error, got.Error)
+ }
+ if want.Error == nil {
+ return
+ }
+ if want.Error.GoType != got.Error.GoType || want.Error.Message != got.Error.Message || want.Error.ExitCode != got.Error.ExitCode {
+ t.Fatalf("error mismatch:\ngolden: %#v\ncurrent: %#v", want.Error, got.Error)
+ }
+ var wantJSON interface{}
+ if err := json.Unmarshal(want.Error.JSON, &wantJSON); err != nil {
+ t.Fatalf("decode golden error JSON: %v", err)
+ }
+ var gotJSON interface{}
+ if err := json.Unmarshal(got.Error.JSON, &gotJSON); err != nil {
+ t.Fatalf("decode current error JSON: %v", err)
+ }
+ if !reflect.DeepEqual(wantJSON, gotJSON) {
+ t.Fatalf("error JSON mismatch:\ngolden: %s\ncurrent: %s", want.Error.JSON, got.Error.JSON)
+ }
+}
+
+func assertEquivalentError(t *testing.T, legacy, current error) {
+ t.Helper()
+ if (legacy == nil) != (current == nil) {
+ t.Fatalf("error presence mismatch: legacy=%v Emitter=%v", legacy, current)
+ }
+ if legacy == nil {
+ return
+ }
+ legacyProblem, legacyOK := errs.ProblemOf(legacy)
+ currentProblem, currentOK := errs.ProblemOf(current)
+ if legacyOK != currentOK {
+ t.Fatalf("typed error mismatch: legacy=%T Emitter=%T", legacy, current)
+ }
+ if legacyOK && !reflect.DeepEqual(legacyProblem, currentProblem) {
+ t.Fatalf("problem mismatch:\nlegacy: %#v\nEmitter: %#v", legacyProblem, currentProblem)
+ }
+}
diff --git a/internal/output/envelope_success.go b/internal/output/envelope_success.go
index e54802a07..0b325cd5d 100644
--- a/internal/output/envelope_success.go
+++ b/internal/output/envelope_success.go
@@ -34,27 +34,17 @@ func SuccessEnvelopeData(result interface{}) interface{} {
// JSON output carries content-safety alerts inside the envelope. When jq is
// applied, the alert may be filtered away, so warn mode also writes stderr.
func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
- scanResult := ScanForSafety(opts.CommandPath, data, opts.ErrOut)
- if scanResult.Blocked {
- return scanResult.BlockErr
- }
-
- env := Envelope{
- OK: true,
- Identity: opts.Identity,
- DryRun: opts.DryRun,
- Data: data,
- Notice: GetNotice(),
- }
- if scanResult.Alert != nil {
- env.ContentSafetyAlert = scanResult.Alert
- }
- if opts.JqExpr != "" {
- if scanResult.Alert != nil && opts.ErrOut != nil {
- WriteAlertWarning(opts.ErrOut, scanResult.Alert)
- }
- return JqFilter(opts.Out, env, opts.JqExpr)
- }
- PrintJson(opts.Out, env)
- return nil
+ return NewEmitter(EmitterConfig{
+ Out: opts.Out,
+ ErrOut: opts.ErrOut,
+ CommandPath: opts.CommandPath,
+ Identity: opts.Identity,
+ NoticeProvider: GetNotice,
+ }).Success(data, EmitOptions{
+ Format: "",
+ Raw: false,
+ JQ: opts.JqExpr,
+ DryRun: opts.DryRun,
+ JQSafetyWarning: true,
+ })
}
diff --git a/internal/output/format.go b/internal/output/format.go
index 7bb4088f7..6469c875d 100644
--- a/internal/output/format.go
+++ b/internal/output/format.go
@@ -101,34 +101,44 @@ func ExtractItems(data interface{}) []interface{} {
// FormatValue formats a single response and writes it to w.
func FormatValue(w io.Writer, data interface{}, format Format) {
+ err := WriteFormatted(w, data, format)
+ switch {
+ case err == nil:
+ return
+ case isOutputMarshalError(err) && format == FormatNDJSON:
+ legacyStderrf("ndjson marshal error: %v\n", err)
+ case isOutputMarshalError(err):
+ legacyStderrf("json marshal error: %v\n", err)
+ }
+}
+
+// WriteFormatted formats a single response and returns marshal or write errors.
+func WriteFormatted(w io.Writer, data interface{}, format Format) error {
data = toGeneric(data)
switch format {
case FormatNDJSON:
items := ExtractItems(data)
if items != nil {
- PrintNdjson(w, items)
- } else {
- PrintNdjson(w, data)
+ return WriteNDJSON(w, items)
}
+ return WriteNDJSON(w, data)
case FormatTable:
items := ExtractItems(data)
if items != nil {
- FormatAsTable(w, items)
- } else {
- FormatAsTable(w, data)
+ return WriteTable(w, items)
}
+ return WriteTable(w, data)
case FormatCSV:
items := ExtractItems(data)
if items != nil {
- FormatAsCSV(w, items)
- } else {
- FormatAsCSV(w, data)
+ return WriteCSV(w, items)
}
+ return WriteCSV(w, data)
default: // FormatJSON
- PrintJson(w, data)
+ return WriteJSON(w, data)
}
}
@@ -148,49 +158,63 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
// FormatPage formats one page of items.
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
- switch pf.Format {
- case FormatJSON, FormatNDJSON:
- if arr, ok := data.([]interface{}); ok {
- PrintNdjson(pf.W, arr)
- } else {
- PrintNdjson(pf.W, data)
- }
-
- case FormatTable:
- pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
- widths := computeColumnWidths(rows, cols)
- if isFirst {
- writeHeader(w, cols, widths)
- }
- for _, row := range rows {
- writeRow(w, row, cols, widths)
- }
- })
-
- case FormatCSV:
- pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
- writeCSVRows(w, rows, cols, isFirst)
- })
+ err := pf.WritePage(data)
+ if isOutputMarshalError(err) && (pf.Format == FormatJSON || pf.Format == FormatNDJSON) {
+ legacyStderrf("ndjson marshal error: %v\n", err)
}
}
+// WritePage formats one page of items and returns marshal or write errors.
+func (pf *PaginatedFormatter) WritePage(data interface{}) error {
+ switch pf.Format {
+ case FormatJSON, FormatNDJSON:
+ if arr, ok := data.([]interface{}); ok {
+ return WriteNDJSON(pf.W, arr)
+ }
+ return WriteNDJSON(pf.W, data)
+
+ case FormatTable:
+ return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
+ widths := computeColumnWidths(rows, cols)
+ if isFirst {
+ if err := writeHeader(w, cols, widths); err != nil {
+ return err
+ }
+ }
+ for _, row := range rows {
+ if err := writeRow(w, row, cols, widths); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+
+ case FormatCSV:
+ return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
+ return writeCSVRows(w, rows, cols, isFirst)
+ })
+ }
+ return nil
+}
+
// formatStructuredPage handles column-locking logic shared by table and csv.
-func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool)) {
+func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool) error) error {
rows, pageCols, isList := prepareRows(data)
if len(rows) == 0 {
if pf.isFirstPage && isList {
- fmt.Fprintln(pf.W, "(empty)")
+ _, err := fmt.Fprintln(pf.W, "(empty)")
+ return err
}
- return
+ return nil
}
if pf.isFirstPage {
// Lock columns from first page
pf.cols = pageCols
pf.isFirstPage = false
- emit(pf.W, rows, pf.cols, true)
+ return emit(pf.W, rows, pf.cols, true)
} else {
// Reuse first page's columns — missing keys become empty, extra keys ignored
- emit(pf.W, rows, pf.cols, false)
+ return emit(pf.W, rows, pf.cols, false)
}
}
diff --git a/internal/output/print.go b/internal/output/print.go
index 104a56da9..8babe5e2f 100644
--- a/internal/output/print.go
+++ b/internal/output/print.go
@@ -5,6 +5,7 @@ package output
import (
"encoding/json"
+ "errors"
"fmt"
"io"
"os"
@@ -15,12 +16,44 @@ import (
// PrintJson prints data as formatted JSON to w.
func PrintJson(w io.Writer, data interface{}) {
injectNotice(data)
+ if err := WriteJSON(w, data); isOutputMarshalError(err) {
+ legacyStderrf("json marshal error: %v\n", err)
+ }
+}
+
+type outputMarshalError struct {
+ err error
+}
+
+func (e *outputMarshalError) Error() string {
+ return e.err.Error()
+}
+
+func (e *outputMarshalError) Unwrap() error {
+ return e.err
+}
+
+func isOutputMarshalError(err error) bool {
+ var marshalErr *outputMarshalError
+ return errors.As(err, &marshalErr)
+}
+
+// legacyStderrf reports a leaf-formatter marshal/format failure on os.Stderr,
+// preserving the pre-Emitter behavior for direct (unmigrated) callers of the
+// Print*/FormatAs* wrappers. The Emitter never uses this — it returns typed
+// errors instead. Removed once the remaining direct callers migrate.
+func legacyStderrf(format string, args ...interface{}) {
+ fmt.Fprintf(os.Stderr, format, args...) //nolint:forbidigo // legacy leaf-formatter stderr; removed in the output-ownership follow-up
+}
+
+// WriteJSON writes data as formatted JSON to w and returns marshal or write errors.
+func WriteJSON(w io.Writer, data interface{}) error {
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
- fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err)
- return
+ return &outputMarshalError{err: err}
}
- fmt.Fprintln(w, string(b))
+ _, err = fmt.Fprintln(w, string(b))
+ return err
}
// injectNotice adds a "_notice" field into CLI envelope maps.
@@ -50,21 +83,38 @@ func injectNotice(data interface{}) {
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
func PrintNdjson(w io.Writer, data interface{}) {
- emit := func(item interface{}) {
+ if arr, ok := data.([]interface{}); ok {
+ for _, item := range arr {
+ if err := WriteNDJSON(w, item); isOutputMarshalError(err) {
+ legacyStderrf("ndjson marshal error: %v\n", err)
+ }
+ }
+ return
+ }
+ if err := WriteNDJSON(w, data); isOutputMarshalError(err) {
+ legacyStderrf("ndjson marshal error: %v\n", err)
+ }
+}
+
+// WriteNDJSON writes data as NDJSON and returns marshal or write errors.
+func WriteNDJSON(w io.Writer, data interface{}) error {
+ emit := func(item interface{}) error {
b, err := json.Marshal(item)
if err != nil {
- fmt.Fprintf(os.Stderr, "ndjson marshal error: %v\n", err)
- return
+ return &outputMarshalError{err: err}
}
- fmt.Fprintln(w, string(b))
+ _, err = fmt.Fprintln(w, string(b))
+ return err
}
if arr, ok := data.([]interface{}); ok {
for _, item := range arr {
- emit(item)
+ if err := emit(item); err != nil {
+ return err
+ }
}
- } else {
- emit(data)
+ return nil
}
+ return emit(data)
}
func cellStr(val interface{}) string {
diff --git a/internal/output/table.go b/internal/output/table.go
index 017f32dcf..880ca0ecd 100644
--- a/internal/output/table.go
+++ b/internal/output/table.go
@@ -16,50 +16,69 @@ const maxColWidth = 100
// - map[string]interface{} (single object) → key-value two-column table
// - empty array → "(empty)"
func FormatAsTable(w io.Writer, data interface{}) {
- FormatAsTablePaginated(w, data, true)
+ if err := WriteTable(w, data); isOutputMarshalError(err) {
+ legacyStderrf("json marshal error: %v\n", err)
+ }
+}
+
+// WriteTable formats data as a table and returns marshal or write errors.
+func WriteTable(w io.Writer, data interface{}) error {
+ return WriteTablePaginated(w, data, true)
}
// FormatAsTablePaginated formats data as a table with pagination awareness.
// When isFirstPage is true, outputs the header; otherwise only data rows.
func FormatAsTablePaginated(w io.Writer, data interface{}, isFirstPage bool) {
+ if err := WriteTablePaginated(w, data, isFirstPage); isOutputMarshalError(err) {
+ legacyStderrf("json marshal error: %v\n", err)
+ }
+}
+
+// WriteTablePaginated formats data as a table and returns marshal or write errors.
+func WriteTablePaginated(w io.Writer, data interface{}, isFirstPage bool) error {
rows, cols, isList := prepareRows(data)
if cols == nil {
if isList {
- fmt.Fprintln(w, "(empty)")
+ _, err := fmt.Fprintln(w, "(empty)")
+ return err
} else {
// Not a list and not an object — print as JSON fallback
- PrintJson(w, data)
+ return WriteJSON(w, data)
}
- return
}
if len(rows) == 0 {
if isFirstPage {
- fmt.Fprintln(w, "(empty)")
+ _, err := fmt.Fprintln(w, "(empty)")
+ return err
}
- return
+ return nil
}
if !isList {
// Single object: key-value two-column format
- formatKeyValueTable(w, rows[0], cols)
- return
+ return formatKeyValueTable(w, rows[0], cols)
}
// Calculate column widths (clamped to maxColWidth)
widths := computeColumnWidths(rows, cols)
if isFirstPage {
- writeHeader(w, cols, widths)
+ if err := writeHeader(w, cols, widths); err != nil {
+ return err
+ }
}
for _, row := range rows {
- writeRow(w, row, cols, widths)
+ if err := writeRow(w, row, cols, widths); err != nil {
+ return err
+ }
}
+ return nil
}
// formatKeyValueTable renders a single object as a two-column key-value table.
-func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) {
+func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) error {
maxKeyWidth := 0
for _, col := range cols {
kw := stringWidth(col)
@@ -71,8 +90,11 @@ func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) {
for _, col := range cols {
val := row[col]
val = truncateToWidth(val, maxColWidth)
- fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val)
+ if _, err := fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val); err != nil {
+ return err
+ }
}
+ return nil
}
// computeColumnWidths returns display widths for each column, clamped to maxColWidth.
@@ -99,25 +121,29 @@ func computeColumnWidths(rows []map[string]string, cols []string) []int {
}
// writeHeader writes the header row and separator line.
-func writeHeader(w io.Writer, cols []string, widths []int) {
+func writeHeader(w io.Writer, cols []string, widths []int) error {
var header []string
var sep []string
for i, col := range cols {
header = append(header, padToWidth(col, widths[i]))
sep = append(sep, strings.Repeat("─", widths[i]))
}
- fmt.Fprintln(w, strings.Join(header, " "))
- fmt.Fprintln(w, strings.Join(sep, " "))
+ if _, err := fmt.Fprintln(w, strings.Join(header, " ")); err != nil {
+ return err
+ }
+ _, err := fmt.Fprintln(w, strings.Join(sep, " "))
+ return err
}
// writeRow writes a single data row.
-func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) {
+func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) error {
var cells []string
for i, col := range cols {
val := truncateToWidth(row[col], widths[i])
cells = append(cells, padToWidth(val, widths[i]))
}
- fmt.Fprintln(w, strings.Join(cells, " "))
+ _, err := fmt.Fprintln(w, strings.Join(cells, " "))
+ return err
}
// padToWidth pads a string with spaces to reach the target display width.
diff --git a/internal/output/testdata/runtime_context_legacy.golden.json b/internal/output/testdata/runtime_context_legacy.golden.json
new file mode 100644
index 000000000..f675222b7
--- /dev/null
+++ b/internal/output/testdata/runtime_context_legacy.golden.json
@@ -0,0 +1,107 @@
+{
+ "cases": {
+ "csv": {
+ "stdout": "id,name\n1,Alice\n2,Bob\n",
+ "stderr": ""
+ },
+ "format_raw_json_preserves_html": {
+ "stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
+ "stderr": ""
+ },
+ "jq_invalid_expression": {
+ "stdout": "",
+ "stderr": "error: invalid jq expression: unexpected EOF\n",
+ "error": {
+ "go_type": "*errs.ValidationError",
+ "json": {
+ "type": "validation",
+ "subtype": "invalid_argument",
+ "message": "invalid jq expression: unexpected EOF"
+ },
+ "message": "invalid jq expression: unexpected EOF",
+ "exit_code": 2
+ }
+ },
+ "jq_safety_alert_without_stderr_warning": {
+ "stdout": "1\n",
+ "stderr": ""
+ },
+ "jq_scalar": {
+ "stdout": "Alice\n",
+ "stderr": ""
+ },
+ "json_object": {
+ "stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"enabled\": true,\n \"id\": \"1\"\n }\n}\n",
+ "stderr": ""
+ },
+ "metadata": {
+ "stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": [\n {\n \"id\": \"1\"\n }\n ],\n \"meta\": {\n \"count\": 1,\n \"rollback\": \"lark-cli fixture rollback\"\n }\n}\n",
+ "stderr": ""
+ },
+ "ndjson": {
+ "stdout": "{\"id\":\"1\"}\n{\"id\":\"2\"}\n",
+ "stderr": ""
+ },
+ "notice": {
+ "stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n },\n \"_notice\": {\n \"update\": {\n \"latest\": \"9.9.9\"\n }\n }\n}\n",
+ "stderr": ""
+ },
+ "partial_failure_ok_false": {
+ "stdout": "{\n \"ok\": false,\n \"identity\": \"bot\",\n \"data\": {\n \"failed\": 1,\n \"succeeded\": 1\n }\n}\n",
+ "stderr": "",
+ "error": {
+ "go_type": "*output.PartialFailureError",
+ "json": {
+ "Code": 1
+ },
+ "message": "partial failure (exit 1)",
+ "exit_code": 1
+ }
+ },
+ "pretty": {
+ "stdout": "pretty:fixture\n",
+ "stderr": ""
+ },
+ "pretty_without_renderer": {
+ "stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"name\": \"Alice\"\n }\n}\n",
+ "stderr": ""
+ },
+ "raw_jq_complex": {
+ "stdout": "{\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n}\n",
+ "stderr": ""
+ },
+ "raw_json_preserves_html": {
+ "stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
+ "stderr": ""
+ },
+ "scanner_block": {
+ "stdout": "",
+ "stderr": "",
+ "error": {
+ "go_type": "*errs.ContentSafetyError",
+ "json": {
+ "type": "policy",
+ "subtype": "content_safety",
+ "message": "content safety violation detected (rules: fixture-rule)",
+ "rules": [
+ "fixture-rule"
+ ]
+ },
+ "message": "content safety violation detected (rules: fixture-rule)",
+ "exit_code": 6
+ }
+ },
+ "scanner_error_fails_open": {
+ "stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
+ "stderr": "warning: content safety scan error: scanner unavailable\n"
+ },
+ "table_with_safety_warning": {
+ "stdout": "id name \n── ─────\n1 Alice\n",
+ "stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
+ },
+ "unknown_format_data_envelope_notice": {
+ "stdout": "{\n \"_notice\": {\n \"skills\": {\n \"current\": \"1.0.0\"\n }\n },\n \"ok\": true,\n \"value\": \"fixture\"\n}\n",
+ "stderr": "warning: unknown format \"yaml\", falling back to json\n"
+ }
+ }
+}
diff --git a/internal/output/testdata/write_success_envelope_legacy.golden.json b/internal/output/testdata/write_success_envelope_legacy.golden.json
new file mode 100644
index 000000000..f2ca31ac7
--- /dev/null
+++ b/internal/output/testdata/write_success_envelope_legacy.golden.json
@@ -0,0 +1,41 @@
+{
+ "cases": {
+ "dry_run": {
+ "stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"dry_run\": true,\n \"data\": {\n \"api\": []\n }\n}\n",
+ "stderr": ""
+ },
+ "jq": {
+ "stdout": "1\n",
+ "stderr": ""
+ },
+ "jq_safety_warning": {
+ "stdout": "1\n",
+ "stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
+ },
+ "json": {
+ "stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
+ "stderr": ""
+ },
+ "notice": {
+ "stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n },\n \"_notice\": {\n \"update\": {\n \"latest\": \"9.9.9\"\n }\n }\n}\n",
+ "stderr": ""
+ },
+ "scanner_block": {
+ "stdout": "",
+ "stderr": "",
+ "error": {
+ "go_type": "*errs.ContentSafetyError",
+ "json": {
+ "type": "policy",
+ "subtype": "content_safety",
+ "message": "content safety violation detected (rules: fixture-rule)",
+ "rules": [
+ "fixture-rule"
+ ]
+ },
+ "message": "content safety violation detected (rules: fixture-rule)",
+ "exit_code": 6
+ }
+ }
+ }
+}
diff --git a/internal/qualitygate/cmd/comment-audit/main.go b/internal/qualitygate/cmd/comment-audit/main.go
index 4425206d1..95cd46faf 100644
--- a/internal/qualitygate/cmd/comment-audit/main.go
+++ b/internal/qualitygate/cmd/comment-audit/main.go
@@ -19,12 +19,18 @@ import (
type eventPayload struct {
Comment *struct {
Body string `json:"body"`
+ Path string `json:"path"`
} `json:"comment"`
Review *struct {
Body string `json:"body"`
} `json:"review"`
}
+type commentContent struct {
+ Body string
+ Path string
+}
+
func main() {
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
@@ -34,12 +40,11 @@ func main() {
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
os.Exit(2)
}
- body, err := commentBody(*eventPath)
+ diags, err := auditEvent(*eventPath, *kind)
if err != nil {
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
os.Exit(2)
}
- diags := diagnostics(publiccontent.ScanComment(*kind, body))
if len(diags) > 0 {
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
}
@@ -47,32 +52,44 @@ func main() {
os.Exit(report.ExitCode(diags))
}
+func auditEvent(eventPath, kind string) ([]report.Diagnostic, error) {
+ content, err := commentBody(eventPath)
+ if err != nil {
+ return nil, err
+ }
+ return scanCommentContent(kind, content), nil
+}
+
+func scanCommentContent(kind string, content commentContent) []report.Diagnostic {
+ return diagnostics(publiccontent.ScanCommentAtPath(kind, content.Path, content.Body))
+}
+
func auditFailureSummary(count int) string {
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
}
-func commentBody(path string) (string, error) {
+func commentBody(path string) (commentContent, error) {
safePath, err := validate.SafeInputPath(path)
if err != nil {
- return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
+ return commentContent{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
WithParam("--event").
WithCause(err)
}
data, err := vfs.ReadFile(safePath)
if err != nil {
- return "", err
+ return commentContent{}, err
}
var payload eventPayload
if err := json.Unmarshal(data, &payload); err != nil {
- return "", err
+ return commentContent{}, err
}
switch {
case payload.Comment != nil:
- return payload.Comment.Body, nil
+ return commentContent{Body: payload.Comment.Body, Path: payload.Comment.Path}, nil
case payload.Review != nil:
- return payload.Review.Body, nil
+ return commentContent{Body: payload.Review.Body}, nil
default:
- return "", nil
+ return commentContent{}, nil
}
}
diff --git a/internal/qualitygate/cmd/comment-audit/main_test.go b/internal/qualitygate/cmd/comment-audit/main_test.go
index 5e7aea463..10070156d 100644
--- a/internal/qualitygate/cmd/comment-audit/main_test.go
+++ b/internal/qualitygate/cmd/comment-audit/main_test.go
@@ -7,9 +7,11 @@ import (
"errors"
"os"
"path/filepath"
+ "strconv"
"testing"
"github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/qualitygate/publiccontent"
)
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
@@ -32,11 +34,92 @@ func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
if err != nil {
t.Fatalf("commentBody() error = %v", err)
}
- if got != "clean comment" {
- t.Fatalf("comment body = %q", got)
+ if got.Body != "clean comment" || got.Path != "" {
+ t.Fatalf("comment content = %#v", got)
}
}
+func TestCommentBodyReadsReviewCommentPath(t *testing.T) {
+ dir := t.TempDir()
+ if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"test suggestion","path":"cmd/agent/list_test.go"}}`); err != nil {
+ t.Fatal(err)
+ }
+ origDir, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chdir(dir); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = os.Chdir(origDir)
+ })
+
+ got, err := commentBody("event.json")
+ if err != nil {
+ t.Fatalf("commentBody() error = %v", err)
+ }
+ if got.Body != "test suggestion" || got.Path != "cmd/agent/list_test.go" {
+ t.Fatalf("comment content = %#v", got)
+ }
+}
+
+func TestCommentAuditUsesReviewCommentPathForFixtureClassification(t *testing.T) {
+ dir := t.TempDir()
+ body := `CLIENT_SECRET=$(security find-generic-password -w)`
+ event := `{"comment":{"body":` + strconv.Quote(body) + `,"path":"scripts/config_test.sh"}}`
+ if err := writeTestFile(filepath.Join(dir, "event.json"), event); err != nil {
+ t.Fatal(err)
+ }
+ origDir, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chdir(dir); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = os.Chdir(origDir)
+ })
+
+ diags, err := auditEvent("event.json", "pull_request_review_comment")
+ if err != nil {
+ t.Fatalf("auditEvent() error = %v", err)
+ }
+ for _, diag := range diags {
+ if diag.Rule == "public_content_generic_credential" {
+ t.Fatalf("review comment fixture should not be a credential diagnostic: %#v", diags)
+ }
+ }
+ pathless := publiccontent.ScanComment("pull_request_review_comment", body)
+ for _, finding := range pathless {
+ if finding.Rule == "public_content_generic_credential" {
+ return
+ }
+ }
+ t.Fatalf("test precondition failed: pathless comment should be classified as a credential: %#v", pathless)
+}
+
+func TestScanCommentContentPreservesReviewCommentPath(t *testing.T) {
+ providerValue := "gh" + "p_" + "1234567890abcdef" + "1234567890abcdef" + "1234"
+ content := commentContent{
+ Body: `cfg := &Config{AccessToken: "` + providerValue + `"}`,
+ Path: "cmd/agent/list_test.go",
+ }
+
+ diags := scanCommentContent("pull_request_review_comment", content)
+ for _, diag := range diags {
+ if diag.Rule != "public_content_generic_credential" {
+ continue
+ }
+ if diag.File != content.Path {
+ t.Fatalf("credential diagnostic file = %q, want %q", diag.File, content.Path)
+ }
+ return
+ }
+ t.Fatalf("missing provider credential diagnostic: %#v", diags)
+}
+
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "event.json")
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
diff --git a/internal/qualitygate/diff/diff_test.go b/internal/qualitygate/diff/diff_test.go
index 71b117301..2b5f88831 100644
--- a/internal/qualitygate/diff/diff_test.go
+++ b/internal/qualitygate/diff/diff_test.go
@@ -6,10 +6,11 @@ package diff
import (
"context"
"os"
- "os/exec"
"path/filepath"
"reflect"
"testing"
+
+ "github.com/larksuite/cli/internal/testutil/gitcmd"
)
func TestScopeIncludesChangedSkillAndRelatedDomain(t *testing.T) {
@@ -122,8 +123,7 @@ func writeFile(t *testing.T, repo, rel, content string) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
- cmd := exec.Command("git", args...)
- cmd.Dir = repo
+ cmd := gitcmd.Command(repo, args...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
@@ -131,8 +131,7 @@ func runGit(t *testing.T, repo string, args ...string) {
func gitOutput(t *testing.T, repo string, args ...string) string {
t.Helper()
- cmd := exec.Command("git", args...)
- cmd.Dir = repo
+ cmd := gitcmd.Command(repo, args...)
out, err := cmd.Output()
if err != nil {
t.Fatalf("git %v failed: %v", args, err)
diff --git a/internal/qualitygate/publiccontent/collect_test.go b/internal/qualitygate/publiccontent/collect_test.go
index 5ea92779f..3473b0d34 100644
--- a/internal/qualitygate/publiccontent/collect_test.go
+++ b/internal/qualitygate/publiccontent/collect_test.go
@@ -6,10 +6,11 @@ package publiccontent
import (
"context"
"os"
- "os/exec"
"path/filepath"
"strings"
"testing"
+
+ "github.com/larksuite/cli/internal/testutil/gitcmd"
)
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
@@ -23,9 +24,10 @@ func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
runGit(t, repo, "add", "baseline.md")
runGit(t, repo, "commit", "-m", "base")
+ providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
-api_`+`key = "example-public-key"
+api_`+`key = "`+providerValue+`"
`)
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
@@ -199,13 +201,14 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
+ providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
- `{"access_` + `token":"real-json-token"}`,
- `{"client_` + `secret": "real ` + `secret value"}`,
- `{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
- `{"github` + `Token":"real-github-token"}`,
- `{"vendorApi` + `Key":"real-vendor-key"}`,
- `{"slackBot` + `Token":"xoxb-real-token"}`,
+ `{"access_` + `token":"` + providerValue + `"}`,
+ `{"client_` + `secret": "` + providerValue + `"}`,
+ `{"tenantAccess` + `Token":"` + providerValue + `"}`,
+ `{"github` + `Token":"` + providerValue + `"}`,
+ `{"vendorApi` + `Key":"` + providerValue + `"}`,
+ `{"slackBot` + `Token":"xoxb_` + `1234567890abcdef"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add json config")
@@ -215,14 +218,7 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
- for _, forbidden := range []string{
- "real-json-token",
- "real secret value",
- "real-tenant-camel-token",
- "real-github-token",
- "real-vendor-key",
- "xoxb-real-token",
- } {
+ for _, forbidden := range []string{providerValue, "xoxb_" + "1234567890abcdef"} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -306,8 +302,8 @@ func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
count++
}
}
- if count != 3 {
- t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
+ if count != 2 {
+ t.Fatalf("angle-wrapped provider credential findings = %d, want 2: %#v", count, got)
}
}
@@ -338,12 +334,12 @@ func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
count++
}
}
- if count != 7 {
- t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
+ if count != 4 {
+ t.Fatalf("provider-shaped benign-key findings = %d, want 4: %#v", count, got)
}
}
-func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
+func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
@@ -358,15 +354,11 @@ func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
- var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
- count++
+ t.Fatalf("readable metadata values should not be credential findings: %#v", got)
}
}
- if count != 3 {
- t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
- }
}
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
@@ -374,7 +366,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
- accessKey := "AK" + "IAIOSFODNN7EXAMPX"
+ accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
@@ -391,7 +383,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
continue
}
count++
- if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
+ if strings.Contains(item.Excerpt, accessKey) {
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
}
}
@@ -432,7 +424,7 @@ func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
}
}
-func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
+func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
@@ -448,15 +440,11 @@ func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T)
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
- var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
- count++
+ t.Fatalf("readable identifiers should not be credential findings: %#v", got)
}
}
- if count != 4 {
- t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
- }
}
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
@@ -489,12 +477,13 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
+ providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
- "API_KEY_OPENAI: real-openai-key",
- "TOKEN_GITHUB: real-github-token",
- "CLIENT_SECRET_GOOGLE: real-google-secret",
- "SECRET_KEY_BASE: real-secret-key-base",
- "APP_PASSWORD_PROD: real-prod-password",
+ "API_KEY_OPENAI: " + providerValue,
+ "TOKEN_GITHUB: " + providerValue,
+ "CLIENT_SECRET_GOOGLE: " + providerValue,
+ "SECRET_KEY_BASE: " + providerValue,
+ "APP_PASSWORD_PROD: " + providerValue,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
@@ -506,13 +495,7 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
continue
}
count++
- for _, forbidden := range []string{
- "real-openai-key",
- "real-github-token",
- "real-google-secret",
- "real-secret-key-base",
- "real-prod-password",
- } {
+ for _, forbidden := range []string{providerValue} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -621,7 +604,8 @@ func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
- writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
+ providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
+ writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN="+providerValue+"\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add scanner fixtures")
@@ -685,10 +669,11 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "base")
- writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
- writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
+ providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
+ writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN="+providerValue+"\n")
+ writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN="+providerValue+"\n")
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
- writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
+ writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN="+providerValue+"\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add special paths")
@@ -855,8 +840,7 @@ func runGit(t *testing.T, repo string, args ...string) {
if len(args) > 0 && args[0] == "commit" {
args = append([]string{"commit", "--no-verify"}, args[1:]...)
}
- cmd := exec.Command("git", args...)
- cmd.Dir = repo
+ cmd := gitcmd.Command(repo, args...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
@@ -865,8 +849,7 @@ func runGit(t *testing.T, repo string, args ...string) {
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
t.Helper()
- cmd := exec.Command("git", args...)
- cmd.Dir = repo
+ cmd := gitcmd.Command(repo, args...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
diff --git a/internal/qualitygate/publiccontent/comment_audit.go b/internal/qualitygate/publiccontent/comment_audit.go
index 760fdcf99..28f797d68 100644
--- a/internal/qualitygate/publiccontent/comment_audit.go
+++ b/internal/qualitygate/publiccontent/comment_audit.go
@@ -4,8 +4,15 @@
package publiccontent
func ScanComment(kind, body string) []Finding {
+ return ScanCommentAtPath(kind, "", body)
+}
+
+func ScanCommentAtPath(kind, path, body string) []Finding {
if kind == "" {
kind = "comment"
}
- return scanText(kind, "comment", body, false)
+ if path == "" {
+ path = kind
+ }
+ return scanText(path, "comment", body, isDetectorRuleFile(path))
}
diff --git a/internal/qualitygate/publiccontent/comment_audit_test.go b/internal/qualitygate/publiccontent/comment_audit_test.go
index 6d05e675f..65e59bab7 100644
--- a/internal/qualitygate/publiccontent/comment_audit_test.go
+++ b/internal/qualitygate/publiccontent/comment_audit_test.go
@@ -3,7 +3,10 @@
package publiccontent
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
@@ -17,3 +20,60 @@ func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
}
}
}
+
+func TestScanCommentAllowsMermaidCredentialTerminology(t *testing.T) {
+ body := strings.Join([]string{
+ "```mermaid",
+ "sequenceDiagram",
+ " participant Client",
+ " participant AccessTokenHashTransport",
+ " participant SecurityPolicyTransport",
+ " Client->>AccessTokenHashTransport: Send request with bearer token",
+ " AccessTokenHashTransport->>AccessTokenHashTransport: Clone request and inject token hash",
+ " Client -> ClientSecret: Resolve configured credential",
+ " AccessTokenHashTransport->>SecurityPolicyTransport: Forward enriched request",
+ "```",
+ }, "\n")
+
+ got := ScanComment("issue_comment", body)
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("mermaid credential terminology should not be a credential finding: %#v", got)
+ }
+ }
+}
+
+func TestScanCommentDetectsCredentialAssignmentInsideMermaidMessage(t *testing.T) {
+ providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
+ credentialAssignment := "password=" + providerValue
+ body := strings.Join([]string{
+ "```mermaid",
+ "sequenceDiagram",
+ " Client->>Server: Send " + credentialAssignment,
+ "```",
+ }, "\n")
+
+ got := ScanComment("issue_comment", body)
+ if !findingRules(got)["public_content_generic_credential"] {
+ t.Fatalf("credential assignment inside mermaid message should be reported: %#v", got)
+ }
+}
+
+func TestScanCommentAtPathAllowsTestFixtureCredentialPlaceholder(t *testing.T) {
+ body := `cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret"}`
+ got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("review comment test fixture should not be a credential finding: %#v", got)
+ }
+ }
+}
+
+func TestScanCommentAtPathDetectsProviderCredentialInTestFile(t *testing.T) {
+ providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
+ body := `cfg := &Config{AccessToken: "` + providerValue + `"}`
+ got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
+ if !findingRules(got)["public_content_generic_credential"] {
+ t.Fatalf("provider credential in review comment should be reported: %#v", got)
+ }
+}
diff --git a/internal/qualitygate/publiccontent/credential.go b/internal/qualitygate/publiccontent/credential.go
new file mode 100644
index 000000000..41819e681
--- /dev/null
+++ b/internal/qualitygate/publiccontent/credential.go
@@ -0,0 +1,88 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package publiccontent
+
+import (
+ "encoding/base64"
+ "net/url"
+ "strings"
+)
+
+func credentialValueHasStrongEvidence(key, value string) bool {
+ normalized := strings.TrimRight(strings.TrimSpace(value), ",;")
+ normalized = strings.TrimSpace(strings.Trim(normalized, `"'<>`))
+ candidates := credentialEvidenceCandidates(unwrapCredentialValue(normalized))
+ for _, candidate := range candidates {
+ if providerCredentialIdentifier(candidate) {
+ return true
+ }
+ }
+ if isCredentialMetadataField(key) {
+ return false
+ }
+ for _, candidate := range candidates {
+ if highEntropyCredentialValue(strings.ToLower(candidate)) || base64PaddedCredentialValue(candidate) {
+ return true
+ }
+ }
+ return percentEncodedCredentialValue(strings.ToLower(candidates[0])) ||
+ commandSubstitutionLooksCredentialLike(strings.ToLower(normalized))
+}
+
+func credentialEvidenceCandidates(value string) []string {
+ candidates := []string{value}
+ for range 3 {
+ decoded, err := url.PathUnescape(value)
+ if err != nil || decoded == value {
+ break
+ }
+ candidates = append(candidates, decoded)
+ value = decoded
+ }
+ return candidates
+}
+
+func isCredentialMetadataField(key string) bool {
+ if isBenignTokenField(key) {
+ return true
+ }
+ parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
+ if len(parts) < 2 {
+ return false
+ }
+ switch parts[len(parts)-1] {
+ case "hash", "id", "kind", "marker", "prefix", "transport":
+ return true
+ default:
+ return false
+ }
+}
+
+func base64PaddedCredentialValue(value string) bool {
+ if len(value) < 16 || !strings.HasSuffix(value, "=") {
+ return false
+ }
+ if _, err := base64.StdEncoding.DecodeString(value); err != nil {
+ return false
+ }
+ return shannonEntropy(strings.TrimRight(value, "=")) >= 3.5
+}
+
+func percentEncodedCredentialValue(value string) bool {
+ if len(value) < 16 {
+ return false
+ }
+ var escapes int
+ for i := 0; i+2 < len(value); i++ {
+ if value[i] == '%' && isHexByte(value[i+1]) && isHexByte(value[i+2]) {
+ escapes++
+ i += 2
+ }
+ }
+ return escapes >= 2
+}
+
+func isHexByte(value byte) bool {
+ return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f')
+}
diff --git a/internal/qualitygate/publiccontent/rules.go b/internal/qualitygate/publiccontent/rules.go
index cb35006a5..106d47c0c 100644
--- a/internal/qualitygate/publiccontent/rules.go
+++ b/internal/qualitygate/publiccontent/rules.go
@@ -13,7 +13,7 @@ import (
)
var (
- credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
+ credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*(?::=|[:=])\s*(?:!!str\s+)?(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\x60[^\x60]*\x60)|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\x60\s,}\]]+))`)
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
@@ -383,33 +383,63 @@ func anglePlaceholderIdentifier(value string) bool {
}
func credentialShapedValue(value string) bool {
- normalized := strings.ToLower(strings.Trim(value, `"'<>`))
+ normalized := strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'<>`))
return credentialShapedIdentifier(normalized)
}
func credentialShapedIdentifier(value string) bool {
+ return providerCredentialIdentifier(value)
+}
+
+func providerCredentialIdentifier(value string) bool {
+ value = strings.TrimSpace(value)
switch {
- case strings.HasPrefix(value, "sk_live_"),
- strings.HasPrefix(value, "sk_test_"),
- strings.HasPrefix(value, "ghp_"),
- strings.HasPrefix(value, "gho_"),
- strings.HasPrefix(value, "ghu_"),
- strings.HasPrefix(value, "github_pat_"),
- strings.HasPrefix(value, "xoxb_"),
- strings.HasPrefix(value, "xoxp_"),
- strings.HasPrefix(value, "xoxa_"):
- return true
- case strings.HasPrefix(value, "real-") &&
- (strings.Contains(value, "secret") ||
- strings.Contains(value, "token") ||
- strings.Contains(value, "key") ||
- strings.Contains(value, "password")):
+ case providerTokenWithBody(value, "sk_live_", 16, ""),
+ providerTokenWithBody(value, "sk_test_", 16, ""),
+ providerTokenWithBody(value, "ghp_", 16, ""),
+ providerTokenWithBody(value, "gho_", 16, ""),
+ providerTokenWithBody(value, "ghu_", 16, ""),
+ providerTokenWithBody(value, "github_pat_", 16, "_"),
+ providerTokenWithBody(value, "xoxb_", 16, "-"),
+ providerTokenWithBody(value, "xoxp_", 16, "-"),
+ providerTokenWithBody(value, "xoxa_", 16, "-"),
+ providerTokenWithBody(value, "xoxb-", 16, "-"),
+ providerTokenWithBody(value, "xoxp-", 16, "-"),
+ providerTokenWithBody(value, "xoxa-", 16, "-"),
+ awsAccessKeyIdentifier(value):
return true
default:
return false
}
}
+func providerTokenWithBody(value, prefix string, minBodyLength int, separators string) bool {
+ body, ok := strings.CutPrefix(value, prefix)
+ if !ok || len(body) < minBodyLength {
+ return false
+ }
+ for _, r := range body {
+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune(separators, r) {
+ continue
+ }
+ return false
+ }
+ return true
+}
+
+func awsAccessKeyIdentifier(value string) bool {
+ if len(value) != 20 || (!strings.HasPrefix(value, "AKIA") && !strings.HasPrefix(value, "ASIA")) {
+ return false
+ }
+ for _, r := range value[4:] {
+ if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
+ continue
+ }
+ return false
+ }
+ return true
+}
+
func resourceTokenPlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
switch normalized {
diff --git a/internal/qualitygate/publiccontent/scan.go b/internal/qualitygate/publiccontent/scan.go
index 577697a58..ebf72a060 100644
--- a/internal/qualitygate/publiccontent/scan.go
+++ b/internal/qualitygate/publiccontent/scan.go
@@ -47,15 +47,30 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
inPrivateKey = false
}
- for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
- if !isCredentialAssignmentMatch(match[0]) {
+ for _, location := range credentialAssignmentRE.FindAllStringIndex(line, -1) {
+ rawMatch := line[location[0]:location[1]]
+ if !validCredentialAssignmentStart(line, location[0], rawMatch) {
+ continue
+ }
+ match := credentialAssignmentRE.FindStringSubmatch(rawMatch)
+ if !isCredentialAssignmentMatch(rawMatch) {
continue
}
value := credentialAssignmentValue(match)
- keyName, _ := normalizedCredentialAssignmentKey(match[0])
+ keyName, _ := normalizedCredentialAssignmentKey(rawMatch)
+ evidenceValue := value
+ if sourceCodeFile(file) {
+ if rhs, ok := sourceCodeTypedCredentialRHS(line, location[0], rawMatch); ok {
+ evidenceValue = rhs
+ }
+ }
+ if !(isWebhookCredentialKey(keyName) && webhookAssignmentValueLooksCredentialLike(value)) &&
+ !credentialValueHasStrongEvidence(keyName, evidenceValue) {
+ continue
+ }
if value == "" ||
isNonSecretLiteralValue(value) ||
- isBenignCodeCredentialExpression(file, line, match[0], value) ||
+ isBenignCodeCredentialExpression(file, line, location[0], rawMatch, value) ||
isPlaceholderValue(value) ||
isPermissionScopeIdentifierAssignment(keyName, value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
@@ -64,7 +79,7 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
if looksLikeEqualityComparison(value) {
continue
}
- out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
+ out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(rawMatch)))
}
for _, match := range jwtLikeRE.FindAllString(line, -1) {
if !isJWTToken(match) {
@@ -123,21 +138,43 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
return out
}
+func validCredentialAssignmentStart(line string, start int, match string) bool {
+ if start <= 0 || credentialAssignmentOperator(match) != ":" {
+ return true
+ }
+ prefix := strings.TrimSpace(line[:start])
+ for _, arrow := range []string{"-->>", "->>", "-->", "->"} {
+ if strings.HasSuffix(prefix, arrow) {
+ return false
+ }
+ }
+ return true
+}
+
+func credentialAssignmentOperator(match string) string {
+ key, ok := credentialAssignmentKey(match)
+ if !ok {
+ return ""
+ }
+ rest := strings.TrimSpace(match[len(key):])
+ if strings.HasPrefix(rest, ":=") {
+ return ":="
+ }
+ if strings.HasPrefix(rest, ":") {
+ return ":"
+ }
+ if strings.HasPrefix(rest, "=") {
+ return "="
+ }
+ return ""
+}
+
func isCredentialAssignmentMatch(match string) bool {
- name, value, ok := normalizedCredentialAssignment(match)
+ name, _, ok := normalizedCredentialAssignment(match)
if !ok {
return false
}
- if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
- return true
- }
- if isBenignTokenField(name) && !credentialShapedValue(value) {
- return false
- }
- if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
- return false
- }
- return isExplicitCredentialKey(name)
+ return isExplicitCredentialKey(name) || isWebhookCredentialKey(name)
}
func normalizedCredentialAssignmentKey(match string) (string, bool) {
@@ -288,7 +325,7 @@ func tokenLikePlaceholderKey(key string) bool {
func tokenLikePlaceholderValue(key, value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
- if normalized == "" || credentialShapedIdentifier(normalized) {
+ if normalized == "" || credentialShapedIdentifier(strings.Trim(value, `"'`)) {
return false
}
if authCredentialTokenKey(key) {
@@ -323,52 +360,8 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
return stars >= 6 && alnum > 0
}
-func isWeakTokenCredentialKey(key string) bool {
- if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
- return false
- }
- return key == "token" ||
- strings.HasSuffix(key, "_token") ||
- strings.HasSuffix(key, "-token")
-}
-
-func isStrongTokenCredentialKey(key string) bool {
- parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
- for _, phrase := range [][2]string{
- {"access", "token"},
- {"refresh", "token"},
- {"auth", "token"},
- {"bearer", "token"},
- {"session", "token"},
- {"service", "token"},
- {"bot", "token"},
- {"api", "token"},
- {"secret", "token"},
- } {
- if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
- return true
- }
- }
- return false
-}
-
-func weakTokenValueLooksCredentialLike(value string) bool {
- normalized := strings.ToLower(strings.Trim(value, `"'<>`))
- if normalized == "" ||
- isNonSecretLiteralValue(value) ||
- isPlaceholderValue(value) {
- return false
- }
- candidate := unwrapCredentialValue(normalized)
- return credentialShapedIdentifier(candidate) ||
- highEntropyCredentialValue(candidate) ||
- commandSubstitutionLooksCredentialLike(normalized) ||
- (strings.Contains(normalized, "://") &&
- urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
-}
-
func unwrapCredentialValue(value string) string {
- value = strings.TrimSpace(strings.Trim(value, `"'<>`))
+ value = strings.TrimSpace(strings.Trim(value, "\"'<>`"))
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
}
@@ -488,17 +481,20 @@ func numericStringPlaceholderValue(value string) bool {
return true
}
-func isBenignCodeCredentialExpression(file, line, match, value string) bool {
+func isBenignCodeCredentialExpression(file, line string, matchStart int, match, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
- if !sourceCodeFile(file) || credentialShapedValue(value) {
+ if !sourceCodeFile(file) {
return false
}
- if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
+ if rhs, ok := sourceCodeTypedCredentialRHS(line, matchStart, match); ok {
return isBenignTypedCredentialRHS(rhs)
}
+ if credentialShapedValue(value) {
+ return false
+ }
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
return true
@@ -518,17 +514,16 @@ func isBenignCodeCredentialExpression(file, line, match, value string) bool {
return codeReferenceExpression(normalized)
}
-func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
- idx := strings.Index(line, match)
- if idx < 0 {
+func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (string, bool) {
+ if matchStart < 0 || matchStart+len(match) > len(line) || line[matchStart:matchStart+len(match)] != match {
return "", false
}
key, ok := credentialAssignmentKey(match)
if !ok {
return "", false
}
- rest := strings.TrimSpace(line[idx+len(key):])
- if !strings.HasPrefix(rest, ":") {
+ rest := strings.TrimSpace(line[matchStart+len(key):])
+ if !strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, ":=") {
return "", false
}
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
@@ -536,7 +531,12 @@ func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
if assignmentIdx < 0 {
return "", false
}
- return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
+ rhs := strings.TrimSpace(typeAndRHS[assignmentIdx+1:])
+ parsed := credentialAssignmentRE.FindStringSubmatch("client_secret=" + rhs)
+ if parsed == nil {
+ return rhs, true
+ }
+ return credentialAssignmentValue(parsed), true
}
func isBenignTypedCredentialRHS(value string) bool {
@@ -568,7 +568,7 @@ func credentialAssignmentRawValueQuoted(match string) bool {
func sourceCodeFile(file string) bool {
switch filepath.Ext(file) {
- case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
+ case ".go", ".js", ".jsx", ".py", ".sh", ".ts", ".tsx":
return true
default:
return false
@@ -593,6 +593,7 @@ func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
sourceCodeFakeOrPlaceholderLiteral(literal) ||
sourceCodeCredentialTermLiteral(literal) ||
sourceCodeCredentialPrefixLiteral(literal) ||
+ sourceCodeStringExpressionLiteral(literal) ||
sourceCodeVocabularyLiteral(literal) ||
sourceCodeSchemaTypeLiteral(literal) ||
benignCredentialStatusLiteral(literal)
@@ -685,6 +686,18 @@ func sourceCodeCredentialPrefixLiteral(value string) bool {
}
}
+func sourceCodeStringExpressionLiteral(value string) bool {
+ normalized := strings.TrimSpace(value)
+ if normalized == "" ||
+ credentialShapedIdentifier(normalized) ||
+ highEntropyCredentialValue(strings.ToLower(normalized)) {
+ return false
+ }
+ return strings.Contains(normalized, "${") ||
+ strings.Contains(normalized, "$(") ||
+ (strings.Contains(normalized, `\b`) && strings.ContainsAny(normalized, "|[]{}()+*?"))
+}
+
func sourceCodeVocabularyLiteral(value string) bool {
switch strings.ToLower(value) {
case "bot", "tenant", "user":
@@ -753,7 +766,7 @@ func codeIdentifier(value string) bool {
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
- case "true", "false", "null", "nil", "{", "[":
+ case "true", "false", "null", "nil", "{", "[", `\`:
return true
default:
return false
@@ -980,6 +993,7 @@ func credentialURLPasswordFixture(password string) bool {
normalized := strings.ToLower(strings.Trim(password, `"'`))
switch normalized {
case "p",
+ "p%40ss",
"pass",
"password",
"pat_abc",
diff --git a/internal/qualitygate/publiccontent/scan_test.go b/internal/qualitygate/publiccontent/scan_test.go
index ad8825979..ea670a109 100644
--- a/internal/qualitygate/publiccontent/scan_test.go
+++ b/internal/qualitygate/publiccontent/scan_test.go
@@ -251,26 +251,22 @@ func TestScanFileDoesNotTreatURLEncodedCredentialAsPlaceholder(t *testing.T) {
}
}
-func TestScanFileDoesNotTreatPlaceholderMarkerSubstringsAsPlaceholders(t *testing.T) {
+func TestScanFileAllowsReadablePlaceholderMarkerSubstrings(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"API_KEY=notredactedreal",
"API_KEY=notplaceholdersecret",
"API_KEY=abcxxxxreal",
}, "\n")+"\n"))
- var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
- count++
+ t.Fatalf("readable credential words should not be findings: %#v", got)
}
}
- if count != 3 {
- t.Fatalf("placeholder-marker substring findings = %d, want 3: %#v", count, got)
- }
}
func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
paddedSecretPrefix := "dGhpc2lz" + "YXNlY3JldA"
- paddedTokenPrefix := "YWJj" + "ZGVmZ2g"
+ paddedTokenPrefix := "UTdrMm1O" + "OXBSNHZYOA"
paddedSecret := base64PaddedFixture(paddedSecretPrefix)
paddedToken := base64PaddedFixture(paddedTokenPrefix)
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
@@ -294,17 +290,25 @@ func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
}
}
+func TestScanFileAllowsReadableBase64Lookalike(t *testing.T) {
+ got := ScanFile("docs/config.md", []byte("client_secret=placeholder=\n"))
+ if findingRules(got)["public_content_generic_credential"] {
+ t.Fatalf("readable base64 lookalike should not be a credential finding: %#v", got)
+ }
+}
+
func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
- jsonToken := "real-json-token"
- jsonSecret := "real " + "secret value"
- jsonKey := "real-json-key"
- jsonTenantToken := "real-tenant-json-token"
- jsonAppSecret := "real-app-secret"
- jsonPrefixedKey := "real-prefixed-key"
- jsonTenantCamelToken := "real-tenant-camel-token"
- jsonGithubToken := "real-github-token"
- jsonVendorKey := "real-vendor-key"
- jsonSlackBotToken := "xoxb-real-token"
+ providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
+ jsonToken := providerValue
+ jsonSecret := providerValue
+ jsonKey := providerValue
+ jsonTenantToken := providerValue
+ jsonAppSecret := providerValue
+ jsonPrefixedKey := providerValue
+ jsonTenantCamelToken := providerValue
+ jsonGithubToken := providerValue
+ jsonVendorKey := providerValue
+ jsonSlackBotToken := "xoxb_" + "1234567890abcdef"
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
`{"access_` + `token":"` + jsonToken + `"}`,
`{"client_` + `secret": "` + jsonSecret + `"}`,
@@ -334,12 +338,13 @@ func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
}
func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
+ providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
- "API_KEY_OPENAI: real-openai-key",
- "TOKEN_GITHUB: real-github-token",
- "CLIENT_SECRET_GOOGLE: real-google-secret",
- "SECRET_KEY_BASE: real-secret-key-base",
- "APP_PASSWORD_PROD: real-prod-password",
+ "API_KEY_OPENAI: " + providerValue,
+ "TOKEN_GITHUB: " + providerValue,
+ "CLIENT_SECRET_GOOGLE: " + providerValue,
+ "SECRET_KEY_BASE: " + providerValue,
+ "APP_PASSWORD_PROD: " + providerValue,
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -347,13 +352,7 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
continue
}
count++
- for _, forbidden := range []string{
- "real-openai-key",
- "real-github-token",
- "real-google-secret",
- "real-secret-key-base",
- "real-prod-password",
- } {
+ for _, forbidden := range []string{providerValue} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -364,85 +363,77 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
}
}
-func TestScanFileDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
+func TestScanFileAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_OPENAI: prod_key",
"CLIENT_SECRET_GOOGLE: prod_secret",
"TOKEN_GITHUB: github_token",
"APP_PASSWORD_PROD: prod_password",
}, "\n")+"\n"))
- var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
- count++
+ t.Fatalf("readable identifiers should not be credential findings: %#v", got)
}
}
- if count != 4 {
- t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
- }
}
func TestScanFileDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
- got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
- "API_KEY: <" + stripeLike + ">",
- "SECRET_TOKEN: <" + patLike + ">",
- "CLIENT_SECRET: ",
- }, "\n")+"\n"))
- var count int
- for _, item := range got {
- if item.Rule == "public_content_generic_credential" {
- count++
- }
+ cases := []struct {
+ name string
+ text string
+ want bool
+ }{
+ {name: "stripe", text: "API_KEY: <" + stripeLike + ">", want: true},
+ {name: "github", text: "SECRET_TOKEN: <" + patLike + ">", want: true},
+ {name: "readable", text: "CLIENT_SECRET: ", want: false},
}
- if count != 3 {
- t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
+ })
}
}
func TestScanFileDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
- got := ScanFile("docs/public.json", []byte(strings.Join([]string{
- `{"access_token_expires_in":"` + patLike + `"}`,
- `{"refresh_token_expires_in":"` + stripeLike + `"}`,
- `{"client_secret_status":"real-client-secret-value"}`,
- `{"client_secret_name":"real-client-secret-value"}`,
- `{"app_token":"` + patLike + `"}`,
- `{"sync_token":"` + stripeLike + `"}`,
- `{"target_token":"real-client-secret-value"}`,
- }, "\n")+"\n"))
- var count int
- for _, item := range got {
- if item.Rule == "public_content_generic_credential" {
- count++
- }
+ cases := []struct {
+ name string
+ text string
+ want bool
+ }{
+ {name: "expiry provider token", text: `{"access_token_expires_in":"` + patLike + `"}`, want: true},
+ {name: "expiry provider secret", text: `{"refresh_token_expires_in":"` + stripeLike + `"}`, want: true},
+ {name: "status readable", text: `{"client_secret_status":"real-client-secret-value"}`, want: false},
+ {name: "name readable", text: `{"client_secret_name":"real-client-secret-value"}`, want: false},
+ {name: "app provider token", text: `{"app_token":"` + patLike + `"}`, want: true},
+ {name: "sync provider secret", text: `{"sync_token":"` + stripeLike + `"}`, want: true},
+ {name: "target readable", text: `{"target_token":"real-client-secret-value"}`, want: false},
}
- if count != 7 {
- t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assertGenericCredentialFinding(t, "docs/public.json", tc.text, tc.want)
+ })
}
}
-func TestScanFileDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
+func TestScanFileAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_NAME: prod_key",
"CLIENT_SECRET_NAME: prod_secret",
"SECRET_STATUS: prod_secret",
}, "\n")+"\n"))
- var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
- count++
+ t.Fatalf("readable metadata values should not be credential findings: %#v", got)
}
}
- if count != 3 {
- t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
- }
}
func TestScanFileDetectsAccessKeyCredentials(t *testing.T) {
- accessKey := "AK" + "IAIOSFODNN7EXAMPX"
+ accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY_ID: " + accessKey,
@@ -593,18 +584,18 @@ func TestScanFileAllowsCredentialReferenceValues(t *testing.T) {
func TestScanFileDetectsMalformedGithubExpressionCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
- got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
- "API_KEY=${{" + stripeLike + "}}",
- "TOKEN=${{real-secret-token-value}}",
- }, "\n")+"\n"))
- var count int
- for _, item := range got {
- if item.Rule == "public_content_generic_credential" {
- count++
- }
+ cases := []struct {
+ name string
+ text string
+ want bool
+ }{
+ {name: "provider", text: "API_KEY=${{" + stripeLike + "}}", want: true},
+ {name: "readable", text: "TOKEN=${{real-secret-token-value}}", want: false},
}
- if count != 2 {
- t.Fatalf("malformed GitHub expression credential findings = %d, want 2: %#v", count, got)
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
+ })
}
}
@@ -648,6 +639,7 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@proxy:8080"`,
+ `proxy := "http://user:p%40ss@proxy:8080/path"`,
`repo := "https://u:t@h/r.git"`,
`target := "https://attacker:pw@open.feishu.cn"`,
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
@@ -821,26 +813,36 @@ func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *tes
}
}
-func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
+func TestScanFileAllowsStrongAuthTokenKeysWithoutStrongValueEvidence(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"access_token":"img_abc123"}`,
`{"api_token":"img_live_secret"}`,
`{"service_token":"ab********cd"}`,
`{"bot_token":"board_v3_example"}`,
}, "\n")+"\n"))
- var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
- count++
+ t.Fatalf("token field names alone should not produce findings: %#v", got)
}
}
- if count != 4 {
- t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
- }
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
- got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
+ got := ScanFile("fixtures/calendar_meeting_test.go", []byte(strings.Join([]string{
+ `AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`,
+ `cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}`,
+ `os.WriteFile(path, []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600)`,
+ `rt := &stubRoundTripper{respBody: ` + "`" + `{"access_token":"t","token_type":"Bearer"}` + "`" + `}`,
+ `envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"`,
+ `os.WriteFile(path, []byte("FEISHU_APP_ID=cli_auto\nFEISHU_APP_SECRET=auto_secret\n"), 0600)`,
+ `os.WriteFile(path, []byte("FEISHU_APP_ID=cli_new_app\nFEISHU_APP_SECRET=new_secret\n"), 0600)`,
+ `if got := out.String(); got != "username=x-access-token\npassword=valid-pat\n\n" {`,
+ `if got := out.String(); got != "username=x-access-token\npassword=restored-pat\n\n" {`,
+ `if got := stdout.String(); got != "username=x-access-token\npassword=pat-token\n\n" {`,
+ `return &core.CliConfig{AppID: "dummy", AppSecret: "dummy"}`,
+ `os.WriteFile(path, []byte("API_KEY=replace-me\n"), 0600)`,
+ `body := "APP_ID=\"cli_xxxxx\"\nAPP_SECRET=\"xxxxx\"\n"`,
+ }, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
@@ -848,8 +850,114 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
}
}
+func TestScanFileAllowsCredentialIdentifierFields(t *testing.T) {
+ got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
+ `"api_key_id": "k1",`,
+ `"secret_id": "s1",`,
+ `"token_id": "t1",`,
+ `"private_key_id": "pk1",`,
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("credential identifier fields should not be credential findings: %#v", got)
+ }
+ }
+}
+
+func TestScanFileDetectsCredentialShapedIdentifierFieldValues(t *testing.T) {
+ stripeLike := "sk_" + "live_1234567890abcdef"
+ githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
+ got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
+ `"api_key_id": "` + stripeLike + `",`,
+ `"token_id": "` + githubToken + `",`,
+ }, "\n")+"\n"))
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 2 {
+ t.Fatalf("credential-shaped identifier field findings = %d, want 2: %#v", count, got)
+ }
+}
+
+func TestCredentialShapedValueTrimsWhitespaceBeforeDelimiters(t *testing.T) {
+ providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
+ if !credentialShapedValue(` "` + providerValue + `" `) {
+ t.Fatal("space-padded quoted provider credential should be recognized")
+ }
+}
+
+func TestScanFileDetectsProviderCredentialsAcrossAssignmentSyntaxes(t *testing.T) {
+ providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
+ tests := []struct {
+ name string
+ path string
+ text string
+ }{
+ {name: "Go raw string", path: "pkg/config.go", text: "const clientSecret = `" + providerValue + "`"},
+ {name: "TypeScript template literal", path: "pkg/config.ts", text: "const clientSecret = `" + providerValue + "`;"},
+ {name: "shell backtick", path: "scripts/config.sh", text: "client_secret=`" + providerValue + "`"},
+ {name: "YAML string tag", path: "docs/config.yaml", text: "client_secret: !!str " + providerValue},
+ {name: "YAML string tag double quoted", path: "docs/config.yaml", text: `client_secret: !!str "` + providerValue + `"`},
+ {name: "YAML string tag single quoted", path: "docs/config.yaml", text: `client_secret: !!str '` + providerValue + `'`},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ScanFile(tt.path, []byte(tt.text+"\n"))
+ if !findingRules(got)["public_content_generic_credential"] {
+ t.Fatalf("provider credential should be reported: %#v", got)
+ }
+ })
+ }
+}
+
+func TestScanFileDetectsPercentEncodedProviderCredential(t *testing.T) {
+ providerBody := strings.Join([]string{"1234567890abcdef", "1234567890abcdef", "1234"}, "")
+ tests := []string{
+ "access_token: ghp%" + "5F" + providerBody,
+ "access_token_hash: ghp%" + "255F" + providerBody,
+ }
+ for _, text := range tests {
+ got := ScanFile("docs/config.yaml", []byte(text+"\n"))
+ if !findingRules(got)["public_content_generic_credential"] {
+ t.Fatalf("percent-encoded provider credential should be reported: %#v", got)
+ }
+ }
+}
+
+func TestScanFileRequiresCompleteProviderCredentialFormats(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
+ "token_type: asian",
+ "token_prefix: ASIA",
+ "token_prefix: ghp_",
+ "api_key: sk_live_example",
+ "token_prefix: asianmarketsegment01",
+ "token_prefix: ghp_placeholder_value",
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("incomplete provider prefixes should not be credential findings: %#v", got)
+ }
+ }
+}
+
+func TestScanFileAllowsEncodedTokenMetadataURL(t *testing.T) {
+ got := ScanFile("docs/config.yaml", []byte("token_url: https%3A%2F%2Fexample.invalid/oauth/token\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("encoded token metadata URL should not be credential finding: %#v", got)
+ }
+ }
+}
+
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
- got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
+ got := ScanFile("fixtures/minutes_detail.go", []byte(strings.Join([]string{
+ "var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)",
+ "REALISTIC_TOKEN_RE=\"\\\"${TOKEN_BODY}\\\"|\\`${TOKEN_BODY}\\`|\\\\b${TOKEN_BODY}\\\\b\"",
+ }, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
@@ -927,6 +1035,22 @@ func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
}
}
+func TestScanFileAllowsSourceCodeSyntheticCredentialIdentifiers(t *testing.T) {
+ got := ScanFile("fixtures/sheets_media.go", []byte(strings.Join([]string{
+ `const fakeOfficeTokenPrefix = "fake_office_"`,
+ `const localOfficeTokenPrefix = "local_office_"`,
+ `const imageLiveSecretMarker = "img_live_secret"`,
+ `const imageProdKeyMarker = "img_prod_key"`,
+ `if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {`,
+ `if strings.HasPrefix(spreadsheetToken, localOfficeTokenPrefix) {`,
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("source code token prefix references should not be credential findings: %#v", got)
+ }
+ }
+}
+
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`app_secret=***`,
@@ -941,22 +1065,18 @@ func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
}
}
-func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
+func TestScanFileAllowsPartiallyMaskedCredentialValues(t *testing.T) {
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
"client_secret=realprefix***realsuffix",
"client_secret=ab********cd",
"access_token=ab********cd",
"refresh_token=realprefix********realsuffix",
}, "\n")+"\n"))
- var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
- count++
+ t.Fatalf("partially masked values should not be credential findings: %#v", got)
}
}
- if count != 4 {
- t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
- }
}
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
@@ -972,6 +1092,7 @@ func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
}
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
+ providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
file string
@@ -980,32 +1101,47 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
{
name: "typescript simple secret",
file: "fixtures/source_secret.ts",
- text: `const clientSecret: string = "real-client-secret-value"`,
+ text: `const clientSecret: string = "` + providerValue + `"`,
},
{
- name: "typescript numeric password",
+ name: "typescript terminated secret",
file: "fixtures/source_secret.ts",
- text: `const password: string = "12345678901234567890"`,
+ text: `const clientSecret: string = "` + providerValue + `";`,
+ },
+ {
+ name: "typescript secret with trailing comment",
+ file: "fixtures/source_secret.ts",
+ text: `const clientSecret: string = "` + providerValue + `"; // production`,
+ },
+ {
+ name: "typescript asserted secret",
+ file: "fixtures/source_secret.ts",
+ text: `const clientSecret: string = "` + providerValue + `" as const;`,
+ },
+ {
+ name: "typescript provider password",
+ file: "fixtures/source_secret.ts",
+ text: `const password: string = "` + providerValue + `"`,
},
{
name: "typescript union secret",
file: "fixtures/source_secret.ts",
- text: `const clientSecret: string | undefined = "real-client-secret-value"`,
+ text: `const clientSecret: string | undefined = "` + providerValue + `"`,
},
{
name: "python simple secret",
file: "fixtures/source_secret.py",
- text: `self.client_secret: str = "real-client-secret-value"`,
+ text: `self.client_secret: str = "` + providerValue + `"`,
},
{
name: "python union secret",
file: "fixtures/source_secret.py",
- text: `self.client_secret: str | None = "real-client-secret-value"`,
+ text: `self.client_secret: str | None = "` + providerValue + `"`,
},
{
name: "python optional secret",
file: "fixtures/source_secret.py",
- text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
+ text: `self.client_secret: Optional[str] = "` + providerValue + `"`,
},
}
for _, tc := range cases {
@@ -1018,24 +1154,154 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
}
}
-func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
- githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
- got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
- `const ClientSecret = "real-client-secret-value"`,
- `const GithubToken = "` + githubToken + `"`,
- `const Password = "12345678901234567890"`,
- `const ClientSecretNumber = "12345678901234567890"`,
- `const ClientSecretFormat = "abc%sdefreal"`,
- `fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
- }, "\n")+"\n"))
+func TestScanFileDetectsRepeatedTypedCredentialAssignments(t *testing.T) {
+ providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
+ assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "placeholder";`, false)
+ assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "`+providerValue+`";`, true)
+
+ got := ScanFile("fixtures/source_secret.ts", []byte(
+ `const clientSecret: string = "placeholder"; const clientSecret: string = "`+providerValue+`";`+"\n",
+ ))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
- if count != 6 {
- t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
+ if count != 1 {
+ t.Fatalf("repeated typed credential findings = %d, want 1: %#v", count, got)
+ }
+}
+
+func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
+ stripeLike := "sk_" + "live_1234567890abcdef"
+ githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
+ cases := []struct {
+ name string
+ text string
+ want bool
+ }{
+ {name: "stripe", text: `const ClientSecret = "` + stripeLike + `"`, want: true},
+ {name: "github", text: `const GithubToken = "` + githubToken + `"`, want: true},
+ {name: "password number", text: `const Password = "12345678901234567890"`, want: false},
+ {name: "secret number", text: `const ClientSecretNumber = "12345678901234567890"`, want: false},
+ {name: "format literal", text: `const ClientSecretFormat = "abc%sdefreal"`, want: false},
+ {name: "inline format literal", text: `fmt.Println("done"); const ClientSecret = "abc%sdefreal"`, want: false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assertGenericCredentialFinding(t, "fixtures/source_secret.go", tc.text, tc.want)
+ })
+ }
+}
+
+func TestScanFileDetectsGoShortDeclarationCredentials(t *testing.T) {
+ providerSecret := "sk_" + "live_1234567890abcdef"
+ providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
+ got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
+ `clientSecret := "` + providerSecret + `"`,
+ `accessToken := "` + providerToken + `"`,
+ }, "\n")+"\n"))
+
+ var count int
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ count++
+ }
+ }
+ if count != 2 {
+ t.Fatalf("Go short declaration credential findings = %d, want 2: %#v", count, got)
+ }
+}
+
+func TestGenericCredentialDecisionMatrix(t *testing.T) {
+ providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
+ highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
+ tokenHash := "6f1ed002ab559585" + "9014ebf0951522d9" +
+ "a0e3c1f4206254d" + "28a13efbbc8d56a30"
+ tests := []struct {
+ name string
+ path string
+ text string
+ comment bool
+ want bool
+ }{
+ {name: "source synthetic token prefix", path: "pkg/sheets.go", text: `const localOfficeTokenPrefix = "local_office_"`, want: false},
+ {name: "source token kind state", path: "pkg/client.py", text: `self._token_kind: TokenKind | None = None`, want: false},
+ {name: "documentation token prefix", path: "docs/config.yaml", text: `token_prefix: local_office_`, want: false},
+ {name: "documentation token kind", path: "docs/config.yaml", text: `token_kind: bearer`, want: false},
+ {name: "documentation token hash", path: "docs/config.yaml", text: `access_token_hash: ` + tokenHash, want: false},
+ {name: "comment fixture placeholder", text: `AppSecret: "fake-secret"`, comment: true, want: false},
+ {name: "test fixture placeholder", path: "pkg/config_test.go", text: `AppSecret: "fake-secret"`, want: false},
+ {name: "test real-labeled token", path: "pkg/config_test.go", text: `token: "real-tenant-access-token"`, want: false},
+ {name: "test ambiguous concrete secret word", path: "pkg/config_test.go", text: `AppSecret: "supersecret"`, want: false},
+ {name: "resource token placeholder", path: "docs/images.md", text: `"token": "img_abc123"`, want: false},
+ {name: "partially masked token", path: "docs/auth.md", text: `token=ab********cd`, want: false},
+ {name: "source readable secret words", path: "pkg/config.go", text: `const AppSecret = "customer-prod-secret"`, want: false},
+ {name: "documentation readable secret words", path: "docs/config.yaml", text: `client_secret: customer-prod-secret`, want: false},
+ {name: "comment middle fixture marker", text: `API_KEY=prod-fake-key`, comment: true, want: false},
+ {name: "comment negated fixture marker", text: `AppSecret: "not-fake-secret"`, comment: true, want: false},
+ {name: "source with credential words", path: "pkg/config.go", text: `secretWithPassword := "hunter2"`, want: false},
+ {name: "production filename containing sample", path: "pkg/sampler.go", text: `clientSecret := "customer-prod-secret"`, want: false},
+ {name: "provider token under weak key", path: "docs/config.yaml", text: `token: ` + providerToken, want: true},
+ {name: "provider token under hash key", path: "docs/config.yaml", text: `access_token_hash: ` + providerToken, want: true},
+ {name: "high entropy strong secret", path: "docs/config.yaml", text: `client_secret: ` + highEntropyValue, want: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var got []Finding
+ if tt.comment {
+ got = ScanComment("issue_comment", tt.text)
+ } else {
+ got = ScanFile(tt.path, []byte(tt.text+"\n"))
+ }
+ if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
+ t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
+ }
+ })
+ }
+}
+
+func TestScanFileClassifiesLowEvidenceTestFixtureCredentials(t *testing.T) {
+ providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
+ highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
+ tests := []struct {
+ name string
+ value string
+ want bool
+ }{
+ {name: "human readable access token", value: "user-access-token", want: false},
+ {name: "delimited secret value", value: "secret-value", want: false},
+ {name: "underscored secret fixture", value: "plain_secret", want: false},
+ {name: "short delimited fixture", value: "t-abc", want: false},
+ {name: "embedded test marker", value: "perm-grant-test-secret-skip", want: false},
+ {name: "real labeled fixture", value: "real-token", want: false},
+ {name: "ambiguous concrete word", value: "supersecret", want: false},
+ {name: "provider token", value: providerToken, want: true},
+ {name: "high entropy secret", value: highEntropyValue, want: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ScanFile("pkg/config_test.go", []byte(`AppSecret: "`+tt.value+`"`+"\n"))
+ if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
+ t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
+ }
+ })
+ }
+}
+
+func TestScanFileAllowsLowEvidenceTestFixtureAssignmentSyntaxes(t *testing.T) {
+ got := ScanFile("pkg/config_test.go", []byte(strings.Join([]string{
+ `secret := "secret-value"`,
+ `samplePassword := "sample-password"`,
+ `bodyWithToken := "plain text body\\nDownload: https://example.com/file?token=tok_aaa\\n"`,
+ }, "\n")+"\n"))
+ for _, item := range got {
+ if item.Rule == "public_content_generic_credential" {
+ t.Fatalf("low-evidence test fixture assignment should not be reported: %#v", got)
+ }
}
}
@@ -1116,9 +1382,10 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
+ githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
`{"client_token":"` + stripeLike + `"}`,
- `{"client_token":"real-client-secret-value"}`,
+ `{"client_token":"` + githubToken + `"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -1152,9 +1419,10 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
+ githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`{ "resource_token": "` + stripeLike + `" }`,
- `{ "block_token": "real-client-secret-value" }`,
+ `{ "block_token": "` + githubToken + `" }`,
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -1368,39 +1636,43 @@ func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
}
}
-func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
+func TestScanFileAllowsInvalidProviderPlaceholderLookalikes(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"client_secret: " + stripeLike + "_HERE",
"api_key: YOUR_" + stripeLike,
}, "\n")+"\n"))
- var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
- count++
+ t.Fatalf("invalid provider placeholder lookalike should not be blocked: %#v", got)
}
}
- if count != 2 {
- t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
- }
}
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
- got := ScanFile("docs/config.md", []byte(strings.Join([]string{
- "CLIENT_SECRET=%" + stripeLike + "%",
- "GITHUB_TOKEN=%" + patLike + "%",
- "TOKEN=%real-secret-token-value%",
- }, "\n")+"\n"))
- var count int
- for _, item := range got {
- if item.Rule == "public_content_generic_credential" {
- count++
- }
+ cases := []struct {
+ name string
+ text string
+ want bool
+ }{
+ {name: "stripe", text: "CLIENT_SECRET=%" + stripeLike + "%", want: true},
+ {name: "github", text: "GITHUB_TOKEN=%" + patLike + "%", want: true},
+ {name: "readable", text: "TOKEN=%real-secret-token-value%", want: false},
}
- if count != 3 {
- t.Fatalf("percent-wrapped credential findings = %d, want 3: %#v", count, got)
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assertGenericCredentialFinding(t, "docs/config.md", tc.text, tc.want)
+ })
+ }
+}
+
+func assertGenericCredentialFinding(t *testing.T, file, text string, want bool) {
+ t.Helper()
+ got := ScanFile(file, []byte(text+"\n"))
+ if actual := findingRules(got)["public_content_generic_credential"]; actual != want {
+ t.Fatalf("generic credential finding = %v, want %v: %#v", actual, want, got)
}
}
diff --git a/internal/qualitygate/rules/dryrun.go b/internal/qualitygate/rules/dryrun.go
index c9cd480ed..586320580 100644
--- a/internal/qualitygate/rules/dryrun.go
+++ b/internal/qualitygate/rules/dryrun.go
@@ -16,6 +16,7 @@ import (
"strings"
"time"
+ "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
@@ -726,7 +727,11 @@ func appendDryRunArg(raw string) ([]string, error) {
return nil, fmt.Errorf("not a lark-cli command")
}
argv = truncateShellTail(argv)
- argv = forceDryRunJSONFormat(argv)
+ var jqValid bool
+ argv, jqValid = stripDryRunJQFilter(argv)
+ if jqValid {
+ argv = forceDryRunJSONFormat(argv)
+ }
hasDryRunArg := false
dryRunEnabled := false
for _, arg := range argv[1:] {
@@ -775,6 +780,73 @@ func truncateShellTail(argv []string) []string {
return argv
}
+// stripDryRunJQFilter removes valid output-only jq filters from the synthetic
+// dry-run invocation. Invalid jq syntax and incompatible output flags are left
+// untouched so the real CLI execution still rejects the documented command.
+// The bool reports whether other output normalization remains safe.
+func stripDryRunJQFilter(argv []string) ([]string, bool) {
+ jqExpr, outputPath, format, hasJQ, jqHasValue := dryRunOutputFlags(argv)
+ if !hasJQ {
+ return argv, true
+ }
+ if !jqHasValue || output.ValidateJqFlags(jqExpr, outputPath, format) != nil {
+ return argv, false
+ }
+
+ out := make([]string, 0, len(argv))
+ for i := 0; i < len(argv); i++ {
+ arg := argv[i]
+ switch {
+ case arg == "--":
+ return append(out, argv[i:]...), true
+ case arg == "--jq" || arg == "-q":
+ i++
+ case strings.HasPrefix(arg, "--jq=") || strings.HasPrefix(arg, "-q="):
+ continue
+ default:
+ out = append(out, arg)
+ }
+ }
+ return out, true
+}
+
+func dryRunOutputFlags(argv []string) (jqExpr, outputPath, format string, hasJQ, jqHasValue bool) {
+ for i := 1; i < len(argv); i++ {
+ arg := argv[i]
+ if arg == "--" {
+ break
+ }
+ switch {
+ case arg == "--jq" || arg == "-q":
+ hasJQ = true
+ jqHasValue = i+1 < len(argv)
+ if jqHasValue {
+ jqExpr = argv[i+1]
+ i++
+ }
+ case strings.HasPrefix(arg, "--jq=") || strings.HasPrefix(arg, "-q="):
+ hasJQ = true
+ jqHasValue = true
+ jqExpr = arg[strings.IndexByte(arg, '=')+1:]
+ case arg == "--output":
+ if i+1 < len(argv) {
+ outputPath = argv[i+1]
+ i++
+ }
+ case strings.HasPrefix(arg, "--output="):
+ outputPath = strings.TrimPrefix(arg, "--output=")
+ case arg == "--format":
+ if i+1 < len(argv) {
+ format = argv[i+1]
+ i++
+ }
+ case strings.HasPrefix(arg, "--format="):
+ format = strings.TrimPrefix(arg, "--format=")
+ }
+ }
+ return jqExpr, outputPath, format, hasJQ, jqHasValue
+}
+
func dryRunFlagExplicitlyTrue(arg string) bool {
value, ok := strings.CutPrefix(arg, "--dry-run=")
if !ok {
diff --git a/internal/qualitygate/rules/dryrun_test.go b/internal/qualitygate/rules/dryrun_test.go
index 59f63f559..dcf280439 100644
--- a/internal/qualitygate/rules/dryrun_test.go
+++ b/internal/qualitygate/rules/dryrun_test.go
@@ -194,6 +194,38 @@ func TestRunDryRunsIgnoresTrailingShellComment(t *testing.T) {
}
}
+func TestRunDryRunsIgnoresJQFilterWhenValidatingRequestPreview(t *testing.T) {
+ cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/flags"}]}`)
+ m := manifest.Manifest{Commands: []manifest.Command{{
+ Path: "im +flag-list",
+ Runnable: true,
+ Identities: []string{"user"},
+ Flags: []manifest.Flag{
+ {Name: "as", TakesValue: true},
+ {Name: "page-all"},
+ {Name: "jq", Shorthand: "q", TakesValue: true},
+ {Name: "dry-run"},
+ },
+ }}}
+ ex := skillscan.Example{
+ Raw: `lark-cli im +flag-list --as user --page-all -q '.data.flag_items[-1]'`,
+ SourceFile: "skills/lark-im/references/lark-im-flag-list.md",
+ Line: 26,
+ }
+
+ diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
+ if len(diags) != 0 {
+ t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
+ }
+ if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
+ t.Fatalf("jq example should remain executable: %#v", facts)
+ }
+ wantArgs := []string{"im", "+flag-list", "--as", "user", "--page-all", "--dry-run"}
+ if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
+ t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
+ }
+}
+
func TestRunDryRunsMaterializesPlaceholdersInsideJSONFlags(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/messages","params":{"chat_id":"oc_test123","page_token":"page_test123"}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
@@ -795,6 +827,72 @@ func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
}
}
+func TestAppendDryRunArgRemovesJQFilter(t *testing.T) {
+ tests := []struct {
+ name string
+ raw string
+ want []string
+ }{
+ {
+ name: "short split",
+ raw: `lark-cli im +flag-list --page-all -q '.data.flag_items[-1]'`,
+ want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
+ },
+ {
+ name: "long split",
+ raw: `lark-cli im +flag-list --jq '.data.flag_items[].item_id' --page-all`,
+ want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
+ },
+ {
+ name: "short inline",
+ raw: `lark-cli im +flag-list -q='.data.flag_items[-1]' --page-all`,
+ want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
+ },
+ {
+ name: "long inline",
+ raw: `lark-cli im +flag-list --jq='.data.flag_items[-1]' --page-all`,
+ want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
+ },
+ {
+ name: "missing value remains invalid",
+ raw: `lark-cli im +flag-list --page-all --jq`,
+ want: []string{"im", "+flag-list", "--page-all", "--jq", "--dry-run"},
+ },
+ {
+ name: "next flag is not accepted as jq expression",
+ raw: `lark-cli im +flag-list --jq --page-all`,
+ want: []string{"im", "+flag-list", "--jq", "--page-all", "--dry-run"},
+ },
+ {
+ name: "invalid expression remains invalid",
+ raw: `lark-cli im +flag-list --jq 'invalid[' --page-all`,
+ want: []string{"im", "+flag-list", "--jq", "invalid[", "--page-all", "--dry-run"},
+ },
+ {
+ name: "incompatible pretty format remains invalid",
+ raw: `lark-cli im +flag-list --jq '.data' --format pretty`,
+ want: []string{"im", "+flag-list", "--jq", ".data", "--format", "pretty", "--dry-run"},
+ },
+ {
+ name: "compatible json format preserves request preview",
+ raw: `lark-cli im +flag-list --jq '.data' --format json`,
+ want: []string{"im", "+flag-list", "--format", "json", "--dry-run"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := appendDryRunArg(tt.raw)
+ if err != nil {
+ t.Fatalf("appendDryRunArg() error = %v", err)
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Fatalf("appendDryRunArg() = %#v, want %#v", got, tt.want)
+ }
+ })
+ }
+}
+
func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
for _, raw := range []string{
"lark-cli mail +watch --format data --dry-run",
diff --git a/internal/qualitygate/rules/run_test.go b/internal/qualitygate/rules/run_test.go
index b60a2c632..e7c2fc348 100644
--- a/internal/qualitygate/rules/run_test.go
+++ b/internal/qualitygate/rules/run_test.go
@@ -7,7 +7,6 @@ import (
"context"
"encoding/json"
"os"
- "os/exec"
"path/filepath"
"strings"
"testing"
@@ -15,6 +14,7 @@ import (
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
+ "github.com/larksuite/cli/internal/testutil/gitcmd"
"github.com/larksuite/cli/internal/vfs"
)
@@ -203,7 +203,8 @@ func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
t.Fatal(err)
}
- publicDoc := "api_" + "key = \"example-public-key\"\n" +
+ providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
+ publicDoc := "api_" + "key = \"" + providerValue + "\"\n" +
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
t.Fatal(err)
@@ -599,7 +600,8 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
- cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
+ commandArgs := append([]string{"-c", "core.hooksPath=/dev/null"}, args...)
+ cmd := gitcmd.Command(repo, commandArgs...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
out, err := cmd.CombinedOutput()
if err != nil {
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index 8906e3f94..9722928d7 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -101,6 +101,7 @@ func TestSelectRecommendedScope_Empty(t *testing.T) {
}
func TestComputeMinimumScopeSet(t *testing.T) {
+ ensureFreshRegistry(t)
minSet := ComputeMinimumScopeSet("user")
if len(minSet) == 0 {
if len(ListFromMetaProjects()) == 0 {
diff --git a/internal/registry/registrytest/fixture_meta.json b/internal/registry/registrytest/fixture_meta.json
new file mode 100644
index 000000000..b33f2d420
--- /dev/null
+++ b/internal/registry/registrytest/fixture_meta.json
@@ -0,0 +1,72 @@
+{
+ "version": "0.0.1",
+ "services": [
+ {
+ "name": "calendar",
+ "version": "v4",
+ "title": "Calendar API",
+ "servicePath": "/open-apis/calendar/v4",
+ "resources": {
+ "events": {
+ "methods": {
+ "create": {
+ "path": "calendars/{calendar_id}/events",
+ "httpMethod": "POST",
+ "risk": "write",
+ "scopes": [
+ "calendar:calendar.event:create"
+ ],
+ "parameters": {
+ "calendar_id": {
+ "type": "string",
+ "location": "path",
+ "required": true
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "im",
+ "version": "v1",
+ "title": "IM API",
+ "servicePath": "/open-apis/im/v1",
+ "resources": {
+ "chat.members": {
+ "methods": {
+ "create": {
+ "path": "chats/{chat_id}/members",
+ "httpMethod": "POST",
+ "risk": "write",
+ "scopes": [
+ "im:chat",
+ "im:chat.members:write_only"
+ ],
+ "parameters": {
+ "chat_id": {
+ "type": "string",
+ "location": "path",
+ "required": true
+ },
+ "member_id_type": {
+ "type": "string",
+ "location": "query",
+ "required": false
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "task",
+ "version": "v2",
+ "title": "Task API",
+ "servicePath": "/open-apis/task/v2",
+ "resources": {}
+ }
+ ]
+}
diff --git a/internal/registry/registrytest/registrytest.go b/internal/registry/registrytest/registrytest.go
new file mode 100644
index 000000000..3b3512a45
--- /dev/null
+++ b/internal/registry/registrytest/registrytest.go
@@ -0,0 +1,146 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+// Package registrytest seeds the registry with a tracked metadata fixture so
+// command-tree tests pass on a clean checkout — no `make fetch_meta`, no
+// network, no user cache. TestMain funcs of packages that build service
+// commands call Seed after redirecting LARKSUITE_CLI_CONFIG_DIR.
+package registrytest
+
+import (
+ _ "embed"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/internal/registry"
+ "github.com/larksuite/cli/internal/vfs"
+)
+
+// fixtureMetaJSON is a trimmed snapshot of the generated meta_data.json
+// holding only the calendar, im and task services that registry-backed tests
+// assert against. Its version is pinned to "0.0.1": newer than the empty
+// embedded stub ("0.0.0") so it wins on a clean checkout, older than any real
+// generated catalog ("1.0.0"+) so a `make fetch_meta` build keeps testing the
+// full embedded data.
+//
+//go:embed fixture_meta.json
+var fixtureMetaJSON []byte
+
+// Seed writes fixtureMetaJSON into the registry remote-meta cache under
+// LARKSUITE_CLI_CONFIG_DIR and eagerly initializes the registry. testRoot must
+// be the temporary root created by the caller's TestMain; Seed rejects a config
+// directory outside it before performing any write. The cache
+// meta is stamped fresh so Init never sync-fetches or background-refreshes
+// over the network. Eager Init pins the catalog for the whole test process before
+// any individual test can re-point LARKSUITE_CLI_CONFIG_DIR elsewhere.
+//
+// The caller's TestMain must set LARKSUITE_CLI_CONFIG_DIR beneath testRoot
+// first; Seed refuses unset, mismatched, or escaping paths so it can never
+// write into a developer's real ~/.lark-cli.
+func Seed(testRoot string) error {
+ configDir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR")
+ if err := validateConfigDir(testRoot, configDir); err != nil {
+ return err
+ }
+
+ var fixture struct {
+ Version string `json:"version"`
+ }
+ if err := json.Unmarshal(fixtureMetaJSON, &fixture); err != nil {
+ return err
+ }
+
+ cacheDir := filepath.Join(configDir, "cache")
+ if err := vfs.MkdirAll(cacheDir, 0o700); err != nil {
+ return err
+ }
+ if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.json"), fixtureMetaJSON, 0o644); err != nil {
+ return err
+ }
+ cacheMeta, err := json.Marshal(registry.CacheMeta{
+ LastCheckAt: time.Now().Unix(),
+ Version: fixture.Version,
+ Brand: string(core.BrandFeishu),
+ })
+ if err != nil {
+ return err
+ }
+ if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.meta.json"), cacheMeta, 0o644); err != nil {
+ return err
+ }
+
+ // Neutralize ambient knobs that would defeat the seeding: an inherited
+ // LARKSUITE_CLI_REMOTE_META=off would stop Init from reading the seeded
+ // cache at all, and LARKSUITE_CLI_META_TTL=0 would expire the freshness
+ // stamp and start a background network refresh from inside unit tests.
+ if err := os.Unsetenv("LARKSUITE_CLI_REMOTE_META"); err != nil {
+ return err
+ }
+ if err := os.Unsetenv("LARKSUITE_CLI_META_TTL"); err != nil {
+ return err
+ }
+
+ registry.Init()
+
+ // Init is a sync.Once, so the seed is pinned for the whole test process.
+ // Turning remote metadata off afterwards cannot un-seed anything; it is a
+ // guard for any future post-Init code path that might consult the remote
+ // cache again after a test re-points LARKSUITE_CLI_CONFIG_DIR elsewhere.
+ if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil {
+ return err
+ }
+
+ // Self-check: both the fixture and any real generated catalog contain the
+ // im service. If it is missing, the cache seeding silently stopped working
+ // (e.g. the registry cache file names or freshness semantics changed) and
+ // every registry-backed test would fail confusingly — fail loudly here
+ // instead, pointing at this package.
+ merged, ok := registry.ServiceTyped("im")
+ if !ok {
+ return errors.New("registrytest.Seed: registry has no im service after seeding — " +
+ "the remote-cache format in internal/registry/remote.go may have changed; update registrytest to match")
+ }
+
+ // Self-check: on a fetch_meta build the real embedded catalog must win over
+ // the 0.0.1 fixture. If the merged im service diverges from the embedded
+ // one, the version arbitration flipped (e.g. the generated catalog version
+ // stopped parsing as semver) and unit tests would silently run against the
+ // stale trimmed fixture instead of the fresh catalog.
+ for _, service := range registry.EmbeddedServicesTyped() {
+ if service.Name != "im" {
+ continue
+ }
+ if service.Version != merged.Version {
+ return errors.New("registrytest.Seed: the fixture shadowed the real embedded catalog — " +
+ "check the meta_data.json version against the fixture's \"0.0.1\" arbitration in this package")
+ }
+ break
+ }
+ return nil
+}
+
+// validateConfigDir guards the one real hazard: a TestMain wiring mistake
+// pointing LARKSUITE_CLI_CONFIG_DIR at a developer's real directory. Both
+// paths come from the caller's own MkdirTemp, so a plain containment check
+// is enough.
+func validateConfigDir(testRoot, configDir string) error {
+ if testRoot == "" || configDir == "" {
+ return errors.New("registrytest.Seed: test root and config dir must be set")
+ }
+ if !filepath.IsAbs(testRoot) || !filepath.IsAbs(configDir) {
+ return errors.New("registrytest.Seed: test root and config dir must be absolute")
+ }
+ rel, err := filepath.Rel(filepath.Clean(testRoot), filepath.Clean(configDir))
+ if err != nil {
+ return err
+ }
+ if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return errors.New("registrytest.Seed: config dir must stay inside the test root")
+ }
+ return nil
+}
diff --git a/internal/registry/registrytest/registrytest_test.go b/internal/registry/registrytest/registrytest_test.go
new file mode 100644
index 000000000..ea7da8b58
--- /dev/null
+++ b/internal/registry/registrytest/registrytest_test.go
@@ -0,0 +1,229 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package registrytest
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+ "slices"
+ "sort"
+ "testing"
+
+ "github.com/larksuite/cli/internal/meta"
+ "github.com/larksuite/cli/internal/registry"
+)
+
+func TestValidateConfigDir(t *testing.T) {
+ root := t.TempDir()
+ tests := []struct {
+ name string
+ testRoot string
+ configDir string
+ wantErr bool
+ }{
+ {name: "equal", testRoot: root, configDir: root},
+ {name: "child", testRoot: root, configDir: filepath.Join(root, "config")},
+ {
+ name: "sibling",
+ testRoot: root,
+ configDir: filepath.Join(filepath.Dir(root), "outside"),
+ wantErr: true,
+ },
+ {name: "empty root", configDir: root, wantErr: true},
+ {name: "empty config", testRoot: root, wantErr: true},
+ {name: "relative root", testRoot: "relative", configDir: root, wantErr: true},
+ {name: "relative config", testRoot: root, configDir: "relative", wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateConfigDir(tt.testRoot, tt.configDir)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("validateConfigDir() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestFixtureContract(t *testing.T) {
+ if len(fixtureMetaJSON) > 20<<10 {
+ t.Fatalf("fixture size = %d, want <= %d", len(fixtureMetaJSON), 20<<10)
+ }
+ reg, err := meta.Parse(fixtureMetaJSON)
+ if err != nil {
+ t.Fatalf("meta.Parse() error = %v", err)
+ }
+ if reg.Version != "0.0.1" {
+ t.Fatalf("fixture version = %q, want 0.0.1", reg.Version)
+ }
+
+ gotNames := make([]string, 0, len(reg.Services))
+ for _, service := range reg.Services {
+ gotNames = append(gotNames, service.Name)
+ }
+ sort.Strings(gotNames)
+ if !slices.Equal(gotNames, []string{"calendar", "im", "task"}) {
+ t.Fatalf("fixture services = %v, want [calendar im task]", gotNames)
+ }
+
+ calendarCreate := fixtureMethod(t, reg, "calendar", "events", "create")
+ assertMethodContract(t, calendarCreate, "calendars/{calendar_id}/events", http.MethodPost)
+ calendarID, ok := calendarCreate.Parameters["calendar_id"]
+ if !ok || calendarID.Location != "path" || !calendarID.Required {
+ t.Fatalf("calendar_id = %+v, want required path parameter", calendarID)
+ }
+ if !slices.Contains(calendarCreate.Scopes, "calendar:calendar.event:create") {
+ t.Fatalf("calendar create scopes = %v, want calendar:calendar.event:create", calendarCreate.Scopes)
+ }
+
+ imCreate := fixtureMethod(t, reg, "im", "chat.members", "create")
+ assertMethodContract(t, imCreate, "chats/{chat_id}/members", http.MethodPost)
+ chatID, ok := imCreate.Parameters["chat_id"]
+ if !ok || chatID.Location != "path" || !chatID.Required {
+ t.Fatalf("chat_id = %+v, want required path parameter", chatID)
+ }
+ memberIDType, ok := imCreate.Parameters["member_id_type"]
+ if !ok || memberIDType.Location != "query" || memberIDType.Required {
+ t.Fatalf("member_id_type = %+v, want optional query parameter", memberIDType)
+ }
+ if imCreate.Risk != "write" {
+ t.Fatalf("im create risk = %q, want write", imCreate.Risk)
+ }
+ for _, scope := range []string{"im:chat", "im:chat.members:write_only"} {
+ if !slices.Contains(imCreate.Scopes, scope) {
+ t.Fatalf("im create scopes = %v, want %s", imCreate.Scopes, scope)
+ }
+ }
+}
+
+func fixtureMethod(t *testing.T, reg meta.Registry, serviceName, resourceName, methodName string) meta.Method {
+ t.Helper()
+ for _, service := range reg.Services {
+ if service.Name != serviceName {
+ continue
+ }
+ resource, ok := service.Resource(resourceName)
+ if !ok {
+ t.Fatalf("fixture service %s has no resource %s", serviceName, resourceName)
+ }
+ method, ok := resource.Method(methodName)
+ if !ok {
+ t.Fatalf("fixture resource %s.%s has no method %s", serviceName, resourceName, methodName)
+ }
+ return method
+ }
+ t.Fatalf("fixture has no service %s", serviceName)
+ return meta.Method{}
+}
+
+func assertMethodContract(t *testing.T, method meta.Method, path, httpMethod string) {
+ t.Helper()
+ if method.Path != path || method.HTTPMethod != httpMethod {
+ t.Fatalf("method = %s %s, want %s %s", method.HTTPMethod, method.Path, httpMethod, path)
+ }
+}
+
+// TestSeedRejectsUnsafeConfigDir pins Seed's guard: it must return before
+// writing anything when LARKSUITE_CLI_CONFIG_DIR is unset or escapes the
+// caller's test root, so a TestMain wiring mistake can never touch a
+// developer's real ~/.lark-cli.
+func TestSeedRejectsUnsafeConfigDir(t *testing.T) {
+ root := t.TempDir()
+
+ t.Run("unset config dir", func(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "")
+ if err := Seed(root); err == nil {
+ t.Fatal("Seed() error = nil, want unset config dir rejection")
+ }
+ })
+
+ t.Run("config dir outside test root", func(t *testing.T) {
+ outside := t.TempDir()
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", outside)
+ if err := Seed(root); err == nil {
+ t.Fatal("Seed() error = nil, want containment rejection")
+ }
+ if _, err := os.Stat(filepath.Join(outside, "cache")); err == nil {
+ t.Fatal("Seed wrote into the rejected config dir")
+ }
+ })
+}
+
+// TestSeedWritesFixtureAndInitializesRegistry covers the seeding happy path:
+// cache files land under the config dir, the registry initializes from them,
+// and both self-checks pass.
+func TestSeedWritesFixtureAndInitializesRegistry(t *testing.T) {
+ root := t.TempDir()
+ configDir := filepath.Join(root, "config")
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
+
+ if err := Seed(root); err != nil {
+ t.Fatalf("Seed() error = %v, want nil", err)
+ }
+ for _, name := range []string{"remote_meta.json", "remote_meta.meta.json"} {
+ if _, err := os.Stat(filepath.Join(configDir, "cache", name)); err != nil {
+ t.Errorf("cache file %s: %v", name, err)
+ }
+ }
+ if got := os.Getenv("LARKSUITE_CLI_REMOTE_META"); got != "off" {
+ t.Errorf("LARKSUITE_CLI_REMOTE_META = %q, want off after seeding", got)
+ }
+ for _, service := range []string{"calendar", "im", "task"} {
+ if _, ok := registry.ServiceTyped(service); !ok {
+ t.Errorf("registry missing service %s after seeding", service)
+ }
+ }
+}
+
+// TestSeedPropagatesCacheSetupFailures pins that filesystem failures while
+// materializing the cache surface as errors instead of leaving the registry
+// silently unseeded. Each obstacle is a same-named file/directory in the
+// way, which fails on every platform without permission tricks.
+func TestSeedPropagatesCacheSetupFailures(t *testing.T) {
+ seedWith := func(t *testing.T, prepare func(root, configDir string)) error {
+ t.Helper()
+ root := t.TempDir()
+ configDir := filepath.Join(root, "config")
+ prepare(root, configDir)
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
+ return Seed(root)
+ }
+
+ t.Run("cache dir creation fails", func(t *testing.T) {
+ err := seedWith(t, func(root, configDir string) {
+ // config is a regular file, so MkdirAll(config/cache) fails.
+ if err := os.WriteFile(configDir, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if err == nil {
+ t.Fatal("Seed() error = nil, want cache dir creation failure")
+ }
+ })
+
+ t.Run("fixture write fails", func(t *testing.T) {
+ err := seedWith(t, func(root, configDir string) {
+ // remote_meta.json is a directory, so WriteFile fails.
+ if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.json"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if err == nil {
+ t.Fatal("Seed() error = nil, want fixture write failure")
+ }
+ })
+
+ t.Run("cache meta write fails", func(t *testing.T) {
+ err := seedWith(t, func(root, configDir string) {
+ // remote_meta.meta.json is a directory, so WriteFile fails.
+ if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.meta.json"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if err == nil {
+ t.Fatal("Seed() error = nil, want cache meta write failure")
+ }
+ })
+}
diff --git a/internal/registry/testmain_test.go b/internal/registry/testmain_test.go
new file mode 100644
index 000000000..357c9140f
--- /dev/null
+++ b/internal/registry/testmain_test.go
@@ -0,0 +1,27 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package registry
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestMain(m *testing.M) {
+ root, err := os.MkdirTemp("", "lark-cli-registry-test-*")
+ if err != nil {
+ panic(err)
+ }
+ if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
+ panic(err)
+ }
+ code := m.Run()
+ // A test that ran Init without a trailing resetInit can leave a background
+ // refresh goroutine alive; removing the temp root while it writes would
+ // let it recreate the directory after cleanup. Wait it out first.
+ waitBackgroundRefresh()
+ _ = os.RemoveAll(root)
+ os.Exit(code)
+}
diff --git a/internal/riskcontrol/osmodel.go b/internal/riskcontrol/osmodel.go
new file mode 100644
index 000000000..7691e76aa
--- /dev/null
+++ b/internal/riskcontrol/osmodel.go
@@ -0,0 +1,142 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+// Package deviceinfo collects the platform hardware product model and the
+// platform values used by device-related risk-control headers.
+package riskcontrol
+
+import (
+ "runtime"
+ "strings"
+ "sync"
+ "unicode"
+ "unicode/utf8"
+
+ "golang.org/x/net/http/httpguts"
+)
+
+// OSType is the server-side risk-control operating-system enum.
+type OSType string
+
+// OS type enum values for X-Agent-Os-Type.
+const (
+ OSTypeUnknown = "0"
+ OSTypeWindows = "1"
+ OSTypeLinux = "2"
+ OSTypeMacOS = "3"
+)
+
+const (
+ // TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
+ TerminalTypePC = "1"
+
+ // Unknown is used when the hardware product model cannot be collected.
+ Unknown = "Unknown"
+
+ // deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
+ // Device models are short identifiers; a larger value is treated as
+ // malformed rather than truncated so the header never misrepresents it.
+ deviceModelMaxBytes = 256
+)
+
+// Snapshot contains the deliberately small risk-control signal set.
+// ProductModel is omitted when the platform cannot provide a safe value.
+type Snapshot struct {
+ OSType OSType
+ ProductModel string
+}
+
+// Source supplies one immutable process-level snapshot.
+type Source interface {
+ Snapshot() Snapshot
+}
+
+// HostSource lazily reads host signals once, after outbound policy authorizes
+// the first request. Failed probes are cached and are not retried per request.
+type HostSource struct {
+ once sync.Once
+ value Snapshot
+ readModel func() string
+}
+
+// NewHostSource creates the production host signal source.
+func NewHostSource() *HostSource {
+ return &HostSource{readModel: readDeviceModel}
+}
+
+// Snapshot returns the cached host signal snapshot.
+func (s *HostSource) Snapshot() Snapshot {
+ if s == nil {
+ return Snapshot{}
+ }
+ s.once.Do(func() {
+ readModel := s.readModel
+ if readModel == nil {
+ readModel = readDeviceModel
+ }
+ s.value = Snapshot{
+ OSType: GetOSType(OSName()),
+ ProductModel: normalizeDeviceModel(readModel()),
+ }
+ })
+ return s.value
+}
+
+// normalizeModel removes non-printable characters and returns a model only
+// when the remaining text is safe to use as an HTTP header value. Input that
+// cannot produce a valid model is rejected so Get can fall back to Unknown.
+func normalizeDeviceModel(model string) string {
+ if !utf8.ValidString(model) {
+ return ""
+ }
+ model = strings.Map(func(r rune) rune {
+ switch {
+ case r == '\r' || r == '\n' || r == '\x00':
+ return -1
+ case unicode.IsSpace(r):
+ return ' '
+ case unicode.IsPrint(r):
+ return r
+ default:
+ return -1
+ }
+ }, model)
+
+ model = strings.Join(strings.Fields(model), " ")
+
+ if model == "" || len(model) > deviceModelMaxBytes {
+ return ""
+ }
+ if !httpguts.ValidHeaderFieldValue(model) {
+ return ""
+ }
+ return model
+}
+
+// GetOSType maps a platform name to the X-Agent-Os-Type enum.
+func GetOSType(osName string) OSType {
+ switch osName {
+ case "Windows":
+ return OSTypeWindows
+ case "Linux":
+ return OSTypeLinux
+ case "MacOS":
+ return OSTypeMacOS
+ default:
+ return OSTypeUnknown
+ }
+}
+
+// OSName returns the platform name used by GetOSType.
+func OSName() string {
+ switch runtime.GOOS {
+ case "darwin":
+ return "MacOS"
+ case "windows":
+ return "Windows"
+ case "linux":
+ return "Linux"
+ default:
+ return runtime.GOOS
+ }
+}
diff --git a/internal/riskcontrol/osmodel_darwin.go b/internal/riskcontrol/osmodel_darwin.go
new file mode 100644
index 000000000..a57d26597
--- /dev/null
+++ b/internal/riskcontrol/osmodel_darwin.go
@@ -0,0 +1,27 @@
+//go:build darwin
+
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package riskcontrol
+
+import "golang.org/x/sys/unix"
+
+// readDeviceModel reads the current product key first and falls back to the
+// legacy model key. Trying both keys is more robust than branching on a macOS
+// version because virtualized or restricted environments may expose only one.
+func readDeviceModel() string {
+ return readDarwinDeviceModel(unix.Sysctl)
+}
+
+func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
+ for _, key := range [...]string{"hw.product", "hw.model"} {
+ model, err := readSysctl(key)
+ if err == nil {
+ if model = normalizeDeviceModel(model); model != "" {
+ return model
+ }
+ }
+ }
+ return ""
+}
diff --git a/internal/riskcontrol/osmodel_darwin_test.go b/internal/riskcontrol/osmodel_darwin_test.go
new file mode 100644
index 000000000..2cbff9185
--- /dev/null
+++ b/internal/riskcontrol/osmodel_darwin_test.go
@@ -0,0 +1,48 @@
+//go:build darwin
+
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package riskcontrol
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+)
+
+func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
+ t.Run("product available", func(t *testing.T) {
+ var keys []string
+ got := readDarwinDeviceModel(func(key string) (string, error) {
+ keys = append(keys, key)
+ if key == "hw.product" {
+ return "Mac16,1", nil
+ }
+ return "", errors.New("unexpected fallback")
+ })
+ if got != "Mac16,1" {
+ t.Fatalf("model = %q, want %q", got, "Mac16,1")
+ }
+ if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
+ t.Fatalf("sysctl keys = %v, want %v", keys, want)
+ }
+ })
+
+ t.Run("product unavailable", func(t *testing.T) {
+ var keys []string
+ got := readDarwinDeviceModel(func(key string) (string, error) {
+ keys = append(keys, key)
+ if key == "hw.model" {
+ return "MacBookPro18,3", nil
+ }
+ return "", errors.New("not available")
+ })
+ if got != "MacBookPro18,3" {
+ t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
+ }
+ if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
+ t.Fatalf("sysctl keys = %v, want %v", keys, want)
+ }
+ })
+}
diff --git a/internal/riskcontrol/osmodel_linux.go b/internal/riskcontrol/osmodel_linux.go
new file mode 100644
index 000000000..3752d7b71
--- /dev/null
+++ b/internal/riskcontrol/osmodel_linux.go
@@ -0,0 +1,17 @@
+//go:build linux
+
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package riskcontrol
+
+// readDeviceModel returns a stable device model for Linux. DMI and device-tree
+// values vary widely and can expose the host or virtualization platform when
+// the CLI runs in a container or sandbox.
+func readDeviceModel() string {
+ return readLinuxDeviceModel()
+}
+
+func readLinuxDeviceModel() string {
+ return "linux"
+}
diff --git a/internal/riskcontrol/osmodel_linux_test.go b/internal/riskcontrol/osmodel_linux_test.go
new file mode 100644
index 000000000..53bf3628a
--- /dev/null
+++ b/internal/riskcontrol/osmodel_linux_test.go
@@ -0,0 +1,20 @@
+//go:build linux
+
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package riskcontrol
+
+import "testing"
+
+func TestReadDeviceModelReturnsLinux(t *testing.T) {
+ if got := readDeviceModel(); got != "linux" {
+ t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
+ }
+}
+
+func TestReadLinuxDeviceModel(t *testing.T) {
+ if got := readLinuxDeviceModel(); got != "linux" {
+ t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
+ }
+}
diff --git a/internal/riskcontrol/osmodel_other.go b/internal/riskcontrol/osmodel_other.go
new file mode 100644
index 000000000..b233dc23c
--- /dev/null
+++ b/internal/riskcontrol/osmodel_other.go
@@ -0,0 +1,11 @@
+//go:build !darwin && !windows && !linux
+
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package riskcontrol
+
+// readDeviceModel returns an empty model on unsupported platforms.
+func readDeviceModel() string {
+ return ""
+}
diff --git a/internal/riskcontrol/osmodel_test.go b/internal/riskcontrol/osmodel_test.go
new file mode 100644
index 000000000..936c329e8
--- /dev/null
+++ b/internal/riskcontrol/osmodel_test.go
@@ -0,0 +1,143 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package riskcontrol
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "unicode"
+)
+
+func TestHostSourceCachesNonEmptyModel(t *testing.T) {
+ calls := 0
+ s := &HostSource{readModel: func() string {
+ calls++
+ return " MacBookPro18,3\n"
+ }}
+
+ if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
+ t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
+ }
+ if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
+ t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
+ }
+ if calls != 1 {
+ t.Fatalf("read called %d times, want 1", calls)
+ }
+}
+
+func TestHostSourceCachesEmptyModel(t *testing.T) {
+ calls := 0
+ s := &HostSource{readModel: func() string {
+ calls++
+ return ""
+ }}
+
+ if got := s.Snapshot(); got.ProductModel != "" {
+ t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
+ }
+ if got := s.Snapshot(); got.ProductModel != "" {
+ t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
+ }
+ if calls != 1 {
+ t.Fatalf("read called %d times, want 1", calls)
+ }
+}
+
+func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
+ var calls atomic.Int32
+ s := &HostSource{readModel: func() string {
+ calls.Add(1)
+ return "ThinkPad X1 Carbon"
+ }}
+
+ const goroutines = 32
+ var wg sync.WaitGroup
+ wg.Add(goroutines)
+ for i := 0; i < goroutines; i++ {
+ go func() {
+ defer wg.Done()
+ snapshot := s.Snapshot()
+ if snapshot.ProductModel != "ThinkPad X1 Carbon" {
+ t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
+ }
+ }()
+ }
+ wg.Wait()
+
+ if got := calls.Load(); got != 1 {
+ t.Fatalf("read called %d times, want 1", got)
+ }
+}
+
+func TestNormalizeDeviceModel(t *testing.T) {
+ tests := []struct {
+ name string
+ model string
+ want string
+ }{
+ {name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
+ {name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
+ {name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
+ {name: "rejects empty", model: " \t\r\n"},
+ {name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
+ {name: "removes CRLF", model: "model\r\nname", want: "modelname"},
+ {name: "normalizes tab", model: "model\tname", want: "model name"},
+ {name: "removes NUL", model: "model\x00name", want: "modelname"},
+ {name: "removes control character", model: "model\x1fname", want: "modelname"},
+ {name: "removes DEL", model: "model\x7fname", want: "modelname"},
+ {name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
+ {name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
+ {name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
+ {name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := normalizeDeviceModel(tt.model); got != tt.want {
+ t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
+ for value := 0; value <= 0x7f; value++ {
+ if value >= 0x20 && value < 0x7f {
+ continue
+ }
+ t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
+ model := "model" + string(rune(value)) + "name"
+ want := "modelname"
+ if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
+ want = "model name"
+ }
+ if got := normalizeDeviceModel(model); got != want {
+ t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
+ }
+ })
+ }
+}
+
+func TestGetOSType(t *testing.T) {
+ tests := []struct {
+ name string
+ want OSType
+ }{
+ {name: "Windows", want: OSTypeWindows},
+ {name: "Linux", want: OSTypeLinux},
+ {name: "MacOS", want: OSTypeMacOS},
+ {name: "unknown", want: OSTypeUnknown},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := GetOSType(tt.name); got != tt.want {
+ t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/riskcontrol/osmodel_windows.go b/internal/riskcontrol/osmodel_windows.go
new file mode 100644
index 000000000..4443ab97e
--- /dev/null
+++ b/internal/riskcontrol/osmodel_windows.go
@@ -0,0 +1,44 @@
+//go:build windows
+
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package riskcontrol
+
+import "golang.org/x/sys/windows/registry"
+
+// systemInfoRegistryPaths lists registry locations in device-model lookup order.
+var systemInfoRegistryPaths = [...]string{
+ `HARDWARE\DESCRIPTION\System\BIOS`,
+ `SYSTEM\CurrentControlSet\Control\SystemInformation`,
+ `SYSTEM\HardwareConfig\Current`,
+}
+
+// readDeviceModel returns the first product name found in the Windows registry.
+func readDeviceModel() string {
+ return readWindowsDeviceModel(readWindowsRegistryModel)
+}
+
+func readWindowsRegistryModel(path string) (string, error) {
+ key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
+ if err != nil {
+ return "", err
+ }
+ defer key.Close()
+
+ model, _, err := key.GetStringValue("SystemProductName")
+ return model, err
+}
+
+func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
+ for _, path := range systemInfoRegistryPaths {
+ model, err := readRegistryModel(path)
+ if err != nil {
+ continue
+ }
+ if model = normalizeDeviceModel(model); model != "" {
+ return model
+ }
+ }
+ return ""
+}
diff --git a/internal/riskcontrol/osmodel_windows_test.go b/internal/riskcontrol/osmodel_windows_test.go
new file mode 100644
index 000000000..eb3331d87
--- /dev/null
+++ b/internal/riskcontrol/osmodel_windows_test.go
@@ -0,0 +1,78 @@
+//go:build windows
+
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package riskcontrol
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+)
+
+func TestReadWindowsDeviceModelFallback(t *testing.T) {
+ readError := errors.New("registry read failed")
+ tests := []struct {
+ name string
+ values map[string]string
+ errors map[string]error
+ want string
+ wantPaths []string
+ }{
+ {
+ name: "first path wins",
+ values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
+ want: "Surface Laptop",
+ wantPaths: []string{systemInfoRegistryPaths[0]},
+ },
+ {
+ name: "read failure falls back",
+ errors: map[string]error{
+ systemInfoRegistryPaths[0]: readError,
+ },
+ values: map[string]string{
+ systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
+ },
+ want: "ThinkPad X1 Carbon",
+ wantPaths: systemInfoRegistryPaths[:2],
+ },
+ {
+ name: "empty normalized value falls back",
+ values: map[string]string{
+ systemInfoRegistryPaths[0]: " \r\n\x00",
+ systemInfoRegistryPaths[1]: "Latitude 7450",
+ },
+ want: "Latitude 7450",
+ wantPaths: systemInfoRegistryPaths[:2],
+ },
+ {
+ name: "all paths fail",
+ errors: map[string]error{
+ systemInfoRegistryPaths[0]: readError,
+ systemInfoRegistryPaths[1]: readError,
+ systemInfoRegistryPaths[2]: readError,
+ },
+ wantPaths: systemInfoRegistryPaths[:],
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var paths []string
+ got := readWindowsDeviceModel(func(path string) (string, error) {
+ paths = append(paths, path)
+ if err := tt.errors[path]; err != nil {
+ return "", err
+ }
+ return tt.values[path], nil
+ })
+ if got != tt.want {
+ t.Fatalf("model = %q, want %q", got, tt.want)
+ }
+ if !reflect.DeepEqual(paths, tt.wantPaths) {
+ t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
+ }
+ })
+ }
+}
diff --git a/internal/riskcontrol/transport.go b/internal/riskcontrol/transport.go
new file mode 100644
index 000000000..9beebdb03
--- /dev/null
+++ b/internal/riskcontrol/transport.go
@@ -0,0 +1,138 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package riskcontrol
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/larksuite/cli/internal/core"
+ internaltransport "github.com/larksuite/cli/internal/transport"
+)
+
+const (
+ HeaderProductModel = "X-Agent-Device-Type"
+ HeaderOSType = "X-Agent-Os-Type"
+)
+
+var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
+
+// Transport is the feature's final outbound boundary. It removes caller- or
+// extension-supplied signal headers first and writes trusted values only after
+// authorizing an official SDK origin and authentication state.
+type Transport struct {
+ next http.RoundTripper
+ source Source
+}
+
+// NewTransport creates the final SDK outbound policy boundary. A nil source
+// disables collection and injection while preserving restricted-header
+// stripping for opt-out and extension-credential requests.
+func NewTransport(next http.RoundTripper, source Source) *Transport {
+ if next == nil {
+ next = internaltransport.Fallback()
+ }
+ return &Transport{
+ next: next,
+ source: source,
+ }
+}
+
+// RoundTrip implements http.RoundTripper.
+func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
+ req = req.Clone(req.Context())
+ if req.Header == nil {
+ req.Header = make(http.Header)
+ }
+ stripRestrictedHeaders(req.Header)
+
+ if t.source != nil && t.routeAllowsSignals(req) {
+ snapshot := t.source.Snapshot()
+ if isSupportedOSType(snapshot.OSType) {
+ req.Header.Set(HeaderOSType, string(snapshot.OSType))
+ }
+ if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
+ req.Header.Set(HeaderProductModel, model)
+ }
+ }
+ return t.next.RoundTrip(req)
+}
+
+func isSupportedOSType(value OSType) bool {
+ switch value {
+ case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
+ return true
+ default:
+ return false
+ }
+}
+
+func stripRestrictedHeaders(header http.Header) {
+ for name := range header {
+ for _, restricted := range restrictedHeaders {
+ if strings.EqualFold(name, restricted) {
+ delete(header, name)
+ break
+ }
+ }
+ }
+}
+
+type origin struct {
+ scheme string
+ host string
+ port string
+}
+
+var officialFeishuOrigins = [...]origin{
+ apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
+ apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
+ apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
+ apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
+}
+
+func (t *Transport) routeAllowsSignals(req *http.Request) bool {
+ if req == nil || req.URL == nil {
+ return false
+ }
+ return isOfficialFeishuOrigin(originOf(req.URL))
+}
+
+func originOf(value *url.URL) origin {
+ if value == nil {
+ return origin{}
+ }
+ scheme := strings.ToLower(value.Scheme)
+ port := value.Port()
+ if port == "" {
+ switch scheme {
+ case "https":
+ port = "443"
+ case "http":
+ port = "80"
+ }
+ }
+ return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
+}
+
+func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
+ endpoint, err := url.Parse(endpointURL)
+ if err != nil {
+ return origin{}
+ }
+ return originOf(endpoint)
+}
+
+func isOfficialFeishuOrigin(candidate origin) bool {
+ if candidate.scheme != "https" || candidate.port != "443" {
+ return false
+ }
+ for _, official := range officialFeishuOrigins {
+ if candidate == official {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/riskcontrol/transport_test.go b/internal/riskcontrol/transport_test.go
new file mode 100644
index 000000000..0fc1436af
--- /dev/null
+++ b/internal/riskcontrol/transport_test.go
@@ -0,0 +1,124 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package riskcontrol
+
+import (
+ "net/http"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
+}
+
+type countingSource struct {
+ calls atomic.Int32
+}
+
+func (s *countingSource) Snapshot() Snapshot {
+ s.calls.Add(1)
+ return Snapshot{OSType: OSTypeMacOS, ProductModel: "Mac16,1"}
+}
+
+type staticSource Snapshot
+
+func (s staticSource) Snapshot() Snapshot { return Snapshot(s) }
+
+func TestTransportAuthorizesBeforeCollecting(t *testing.T) {
+ tests := []struct {
+ name string
+ requestURL string
+ authorization string
+ wantSignals bool
+ }{
+ {name: "authenticated official HTTPS", requestURL: "https://open.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
+ {name: "Lark official HTTPS", requestURL: "https://open.larksuite.com/open-apis/test", authorization: "Bearer token", wantSignals: true},
+ {name: "official explicit HTTPS port", requestURL: "https://OPEN.FEISHU.CN:443/open-apis/test", authorization: "Bearer token", wantSignals: true},
+ {name: "unauthenticated", requestURL: "https://open.feishu.cn/open-apis/test", wantSignals: true},
+ {name: "official non-OpenAPI origin", requestURL: "https://accounts.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
+ {name: "off domain", requestURL: "https://example.com/test", authorization: "Bearer token", wantSignals: false},
+ {name: "lookalike", requestURL: "https://open.feishu.cn.evil.example/test", authorization: "Bearer token", wantSignals: false},
+ {name: "plain HTTP", requestURL: "http://open.feishu.cn/test", authorization: "Bearer token", wantSignals: false},
+ {name: "non-default port", requestURL: "https://open.feishu.cn:8443/test", authorization: "Bearer token", wantSignals: false},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ source := &countingSource{}
+ var received http.Header
+ base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ received = req.Header.Clone()
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
+ })
+ req, err := http.NewRequest(http.MethodGet, test.requestURL, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Authorization", test.authorization)
+ req.Header.Set(HeaderOSType, "caller-value")
+ req.Header.Set(HeaderProductModel, "caller-value")
+ req.Header["x-agent-device-type"] = []string{"non-canonical-caller-value"}
+
+ resp, err := NewTransport(base, source).RoundTrip(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+
+ gotSignals := received.Get(HeaderOSType) != ""
+ if gotSignals != test.wantSignals {
+ t.Fatalf("signals present = %t, want %t; headers=%v", gotSignals, test.wantSignals, received)
+ }
+ wantCalls := int32(0)
+ if test.wantSignals {
+ wantCalls = 1
+ }
+ if got := source.calls.Load(); got != wantCalls {
+ t.Fatalf("Snapshot calls = %d, want %d", got, wantCalls)
+ }
+ if got := req.Header.Get(HeaderOSType); got != "caller-value" {
+ t.Fatalf("caller request OS header = %q, want unchanged", got)
+ }
+ if got := req.Header.Get(HeaderProductModel); got != "caller-value" {
+ t.Fatalf("caller request product-model header = %q, want unchanged", got)
+ }
+ if !test.wantSignals {
+ for name := range received {
+ if strings.EqualFold(name, HeaderProductModel) || strings.EqualFold(name, HeaderOSType) {
+ t.Fatalf("restricted header leaked as %q", name)
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestTransportValidatesSourceSnapshot(t *testing.T) {
+ var received http.Header
+ base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ received = req.Header.Clone()
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
+ })
+ req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Authorization", "Bearer token")
+
+ resp, err := NewTransport(base, staticSource{
+ OSType: OSType("unsupported"),
+ ProductModel: "unsafe\nvalue",
+ }).RoundTrip(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if received.Get(HeaderOSType) == "" && received.Get(HeaderProductModel) == "" {
+ t.Fatalf("no signals collected: %v", received)
+ }
+}
diff --git a/internal/testutil/gitcmd/gitcmd.go b/internal/testutil/gitcmd/gitcmd.go
new file mode 100644
index 000000000..e878ed486
--- /dev/null
+++ b/internal/testutil/gitcmd/gitcmd.go
@@ -0,0 +1,55 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+// Package gitcmd provides Git process helpers for tests that use temporary
+// repositories.
+package gitcmd
+
+import (
+ "os"
+ "os/exec"
+ "strconv"
+ "testing"
+)
+
+const (
+ maintenanceAutoDetach = "maintenance.autoDetach"
+ gcAutoDetach = "gc.autoDetach"
+)
+
+// Command creates a Git command whose automatic maintenance stays in the
+// command lifecycle, so temporary repository cleanup cannot race a detached
+// maintenance process.
+func Command(dir string, args ...string) *exec.Cmd {
+ commandArgs := make([]string, 0, len(args)+4)
+ commandArgs = append(commandArgs,
+ "-c", maintenanceAutoDetach+"=false",
+ "-c", gcAutoDetach+"=false",
+ )
+ commandArgs = append(commandArgs, args...)
+ cmd := exec.Command("git", commandArgs...)
+ cmd.Dir = dir
+ return cmd
+}
+
+// SetSynchronousMaintenanceEnv applies the same lifecycle contract to every
+// Git process started by the current test, including processes created through
+// production command runners. Tests using it must not run in parallel.
+func SetSynchronousMaintenanceEnv(t *testing.T) {
+ t.Helper()
+ count := 0
+ if value, ok := os.LookupEnv("GIT_CONFIG_COUNT"); ok {
+ parsed, err := strconv.Atoi(value)
+ if err != nil || parsed < 0 {
+ t.Fatalf("invalid GIT_CONFIG_COUNT %q", value)
+ }
+ count = parsed
+ }
+ for _, key := range []string{maintenanceAutoDetach, gcAutoDetach} {
+ index := strconv.Itoa(count)
+ t.Setenv("GIT_CONFIG_KEY_"+index, key)
+ t.Setenv("GIT_CONFIG_VALUE_"+index, "false")
+ count++
+ }
+ t.Setenv("GIT_CONFIG_COUNT", strconv.Itoa(count))
+}
diff --git a/internal/testutil/gitcmd/gitcmd_test.go b/internal/testutil/gitcmd/gitcmd_test.go
new file mode 100644
index 000000000..96abe1648
--- /dev/null
+++ b/internal/testutil/gitcmd/gitcmd_test.go
@@ -0,0 +1,47 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package gitcmd
+
+import (
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+func TestCommandDisablesDetachedMaintenance(t *testing.T) {
+ for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
+ cmd := Command(t.TempDir(), "config", "--get", "--type=bool", key)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git config %s: %v\n%s", key, err, out)
+ }
+ if got := strings.TrimSpace(string(out)); got != "false" {
+ t.Fatalf("%s = %q, want false", key, got)
+ }
+ }
+}
+
+func TestSetSynchronousMaintenanceEnv(t *testing.T) {
+ t.Setenv("GIT_CONFIG_COUNT", "1")
+ t.Setenv("GIT_CONFIG_KEY_0", "user.name")
+ t.Setenv("GIT_CONFIG_VALUE_0", "Existing Test User")
+ SetSynchronousMaintenanceEnv(t)
+ for key, want := range map[string]string{
+ "user.name": "Existing Test User",
+ maintenanceAutoDetach: "false",
+ gcAutoDetach: "false",
+ } {
+ cmd := exec.Command("git", "config", "--get", "--type=bool", key)
+ if key == "user.name" {
+ cmd = exec.Command("git", "config", "--get", key)
+ }
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git config %s: %v\n%s", key, err, out)
+ }
+ if got := strings.TrimSpace(string(out)); got != want {
+ t.Fatalf("%s = %q, want %q", key, got, want)
+ }
+ }
+}
diff --git a/internal/validate/path.go b/internal/validate/path.go
index 86a4cca5a..dbc5a9d16 100644
--- a/internal/validate/path.go
+++ b/internal/validate/path.go
@@ -23,6 +23,13 @@ func SafeTempAbsInputPath(path string) (string, error) {
return localfileio.SafeTempAbsInputPath(path)
}
+// LocalInputPath validates a local input path without restricting it to the
+// current working directory. It delegates to localfileio.LocalInputPath so
+// command validation and shared local-file readers use one policy.
+func LocalInputPath(path string) (string, error) {
+ return localfileio.LocalInputPath(path)
+}
+
// SafeEnvDirPath validates an environment-provided application directory path.
// Delegates to localfileio.SafeEnvDirPath.
func SafeEnvDirPath(path, envName string) (string, error) {
diff --git a/internal/validate/path_test.go b/internal/validate/path_test.go
index 61e63341f..4873388ea 100644
--- a/internal/validate/path_test.go
+++ b/internal/validate/path_test.go
@@ -211,6 +211,18 @@ func TestSafeLocalFlagPath(t *testing.T) {
}
}
+func TestLocalInputPath_AllowsLocalPathsAndRejectsUnsafeCharacters(t *testing.T) {
+ for _, path := range []string{"/tmp/report.pdf", "../report.pdf"} {
+ got, err := LocalInputPath(path)
+ if err != nil || got != path {
+ t.Fatalf("LocalInputPath(%q) = %q, %v; want unchanged path", path, got, err)
+ }
+ }
+ if _, err := LocalInputPath("report\n.pdf"); err == nil {
+ t.Fatal("LocalInputPath() unexpectedly accepted a control character")
+ }
+}
+
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")
diff --git a/internal/vfs/localfileio/path.go b/internal/vfs/localfileio/path.go
index fe1f6b4cb..94533ba0c 100644
--- a/internal/vfs/localfileio/path.go
+++ b/internal/vfs/localfileio/path.go
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
+ "unicode"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/vfs"
@@ -48,6 +49,32 @@ func SafeTempAbsInputPath(path string) (string, error) {
return resolved, nil
}
+// LocalInputPath validates an input path in the process local filesystem
+// namespace. It intentionally does not impose cwd containment or canonicalize
+// the path: absolute paths, parent-relative paths, and symlink traversal retain
+// their normal OS semantics. Character validation remains mandatory because
+// paths are user-controlled and may appear in errors or progress output.
+func LocalInputPath(path string) (string, error) {
+ if strings.TrimSpace(path) == "" {
+ return "", fmt.Errorf("local input path must not be empty")
+ }
+ if strings.IndexFunc(path, unicode.IsControl) >= 0 {
+ return "", fmt.Errorf("local input path must not contain control characters")
+ }
+ if err := charcheck.RejectControlChars(path, "local input path"); err != nil {
+ return "", err
+ }
+ if err := validateLocalInputPlatform(path); err != nil {
+ return "", err
+ }
+ return path, nil
+}
+
+func isWindowsNonLocalNamespace(path string) bool {
+ normalized := strings.ReplaceAll(path, "/", `\`)
+ return strings.HasPrefix(normalized, `\\`) || strings.HasPrefix(normalized, `\??\`)
+}
+
// SafeLocalFlagPath validates a flag value as a local file path.
// Empty values and http/https URLs are returned unchanged without validation.
func SafeLocalFlagPath(flagName, value string) (string, error) {
@@ -55,7 +82,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
return value, nil
}
if _, err := SafeInputPath(value); err != nil {
- return "", fmt.Errorf("%s: %v", flagName, err)
+ return "", fmt.Errorf("%s: %w", flagName, err)
}
return value, nil
}
diff --git a/internal/vfs/localfileio/path_local_other.go b/internal/vfs/localfileio/path_local_other.go
new file mode 100644
index 000000000..0b00f4992
--- /dev/null
+++ b/internal/vfs/localfileio/path_local_other.go
@@ -0,0 +1,8 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+//go:build !windows
+
+package localfileio
+
+func validateLocalInputPlatform(string) error { return nil }
diff --git a/internal/vfs/localfileio/path_local_windows.go b/internal/vfs/localfileio/path_local_windows.go
new file mode 100644
index 000000000..1f54ca196
--- /dev/null
+++ b/internal/vfs/localfileio/path_local_windows.go
@@ -0,0 +1,33 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+//go:build windows
+
+package localfileio
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+)
+
+func validateLocalInputPlatform(path string) error {
+ if isWindowsNonLocalNamespace(path) {
+ return fmt.Errorf("local input path must not use a Windows network or device namespace")
+ }
+
+ cleaned := filepath.Clean(path)
+ volume := filepath.VolumeName(cleaned)
+ remainder := strings.TrimLeft(cleaned[len(volume):], `\/`)
+ for _, component := range strings.FieldsFunc(remainder, func(r rune) bool {
+ return r == '\\' || r == '/'
+ }) {
+ if component == "." || component == ".." {
+ continue
+ }
+ if !filepath.IsLocal(component) {
+ return fmt.Errorf("local input path contains a reserved Windows path component %q", component)
+ }
+ }
+ return nil
+}
diff --git a/internal/vfs/localfileio/path_local_windows_test.go b/internal/vfs/localfileio/path_local_windows_test.go
new file mode 100644
index 000000000..9e85687c7
--- /dev/null
+++ b/internal/vfs/localfileio/path_local_windows_test.go
@@ -0,0 +1,27 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+//go:build windows
+
+package localfileio
+
+import "testing"
+
+func TestLocalInputPath_RejectsWindowsNetworkDeviceAndReservedPaths(t *testing.T) {
+ for _, input := range []string{
+ `\\server\share\report.pdf`,
+ `//server/share/report.pdf`,
+ `\\.\pipe\upload`,
+ `\\?\C:\Users\agent\report.pdf`,
+ `\\?\UNC\server\share\report.pdf`,
+ `\??\C:\Users\agent\report.pdf`,
+ `C:\Users\agent\NUL.txt`,
+ `CON`,
+ } {
+ t.Run(input, func(t *testing.T) {
+ if _, err := LocalInputPath(input); err == nil {
+ t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
+ }
+ })
+ }
+}
diff --git a/internal/vfs/localfileio/path_test.go b/internal/vfs/localfileio/path_test.go
index 50891ad3d..0a7c205de 100644
--- a/internal/vfs/localfileio/path_test.go
+++ b/internal/vfs/localfileio/path_test.go
@@ -4,6 +4,7 @@
package localfileio
import (
+ "fmt"
"os"
"path/filepath"
"strings"
@@ -71,6 +72,72 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
}
}
+func TestLocalInputPath_AllowsLocalNamespaceWithoutRewriting(t *testing.T) {
+ for _, input := range []string{
+ "/tmp/report.pdf",
+ "../outside/report.pdf",
+ "./report.pdf",
+ "nested/../report.pdf",
+ `C:\Users\agent\report.pdf`,
+ "报告.pdf",
+ } {
+ t.Run(input, func(t *testing.T) {
+ got, err := LocalInputPath(input)
+ if err != nil {
+ t.Fatalf("LocalInputPath(%q) error = %v", input, err)
+ }
+ if got != input {
+ t.Fatalf("LocalInputPath(%q) = %q, want path preserved verbatim", input, got)
+ }
+ })
+ }
+}
+
+func TestWindowsNonLocalNamespace(t *testing.T) {
+ for _, input := range []string{
+ `\\server\share\report.pdf`,
+ `//server/share/report.pdf`,
+ `\\.\pipe\upload`,
+ `\\?\C:\Users\agent\report.pdf`,
+ `\\?\UNC\server\share\report.pdf`,
+ `\??\C:\Users\agent\report.pdf`,
+ } {
+ if !isWindowsNonLocalNamespace(input) {
+ t.Errorf("isWindowsNonLocalNamespace(%q) = false, want true", input)
+ }
+ }
+
+ for _, input := range []string{
+ `C:\Users\agent\report.pdf`,
+ `C:/Users/agent/report.pdf`,
+ `..\outside\report.pdf`,
+ `.\report.pdf`,
+ } {
+ if isWindowsNonLocalNamespace(input) {
+ t.Errorf("isWindowsNonLocalNamespace(%q) = true, want false", input)
+ }
+ }
+}
+
+func TestLocalInputPath_RejectsEmptyControlAndDangerousUnicode(t *testing.T) {
+ for _, input := range []string{
+ "",
+ " ",
+ "file\x00.txt",
+ "file\tname.txt",
+ "file\nname.txt",
+ "file\rname.txt",
+ "file\u202Ename.txt",
+ "file\u200Bname.txt",
+ } {
+ t.Run(fmt.Sprintf("%q", input), func(t *testing.T) {
+ if _, err := LocalInputPath(input); err == nil {
+ t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
+ }
+ })
+ }
+}
+
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
// GIVEN: a clean temp directory as CWD
dir := t.TempDir()
diff --git a/lint/errscontract/scan_test.go b/lint/errscontract/scan_test.go
index 686fe3c01..d2b2b9b8f 100644
--- a/lint/errscontract/scan_test.go
+++ b/lint/errscontract/scan_test.go
@@ -34,7 +34,12 @@ func writeFixture(t *testing.T, files fixtureRepo) string {
func runGit(t *testing.T, root string, args ...string) string {
t.Helper()
- cmd := exec.Command("git", args...)
+ commandArgs := []string{
+ "-c", "maintenance.autoDetach=false",
+ "-c", "gc.autoDetach=false",
+ }
+ commandArgs = append(commandArgs, args...)
+ cmd := exec.Command("git", commandArgs...)
cmd.Dir = root
out, err := cmd.CombinedOutput()
if err != nil {
@@ -43,6 +48,14 @@ func runGit(t *testing.T, root string, args ...string) string {
return strings.TrimSpace(string(out))
}
+func TestRunGitDisablesDetachedMaintenance(t *testing.T) {
+ for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
+ if got := runGit(t, t.TempDir(), "config", "--get", "--type=bool", key); got != "false" {
+ t.Fatalf("%s = %q, want false", key, got)
+ }
+ }
+}
+
func TestLoadSubtypeAllowlist_ExtractsTypedConstValues(t *testing.T) {
root := writeFixture(t, fixtureRepo{
"errs/subtypes.go": `package errs
diff --git a/package-lock.json b/package-lock.json
index 5c63f1dc9..f6140ea4a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,15 +1,16 @@
{
"name": "@larksuite/cli",
- "version": "1.0.11",
+ "version": "1.0.79",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
- "version": "1.0.11",
+ "version": "1.0.79",
"cpu": [
"x64",
- "arm64"
+ "arm64",
+ "riscv64"
],
"hasInstallScript": true,
"license": "MIT",
diff --git a/package.json b/package.json
index 3b93275e4..0bddf4163 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,13 @@
{
"name": "@larksuite/cli",
- "version": "1.0.72",
+ "version": "1.0.79",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
},
"scripts": {
- "postinstall": "node scripts/install.js"
+ "postinstall": "node scripts/install.js",
+ "release:check": "node scripts/release-preflight.js"
},
"os": [
"darwin",
diff --git a/scripts/install.js b/scripts/install.js
index 88a8b74c4..d6809fa9e 100644
--- a/scripts/install.js
+++ b/scripts/install.js
@@ -265,10 +265,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
const checksumsPath = path.join(dir, "checksums.txt");
if (!fs.existsSync(checksumsPath)) {
- console.error(
- "[WARN] checksums.txt not found, skipping checksum verification"
- );
- return null;
+ throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
}
const content = fs.readFileSync(checksumsPath, "utf8");
@@ -286,7 +283,14 @@ function getExpectedChecksum(archiveName, checksumsDir) {
}
function verifyChecksum(archivePath, expectedHash) {
- if (expectedHash === null) return;
+ if (typeof expectedHash !== "string" || expectedHash.length === 0) {
+ throw new Error("[SECURITY] Expected checksum is missing or invalid");
+ }
+ if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
+ throw new Error(
+ "[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
+ );
+ }
// Stream the file to avoid loading the entire archive into memory.
// Archives can be 10-100MB; streaming keeps RSS constant.
diff --git a/scripts/install.test.js b/scripts/install.test.js
index ad669613e..a0ec195dd 100644
--- a/scripts/install.test.js
+++ b/scripts/install.test.js
@@ -52,11 +52,12 @@ describe("getExpectedChecksum", () => {
);
});
- it("returns null when checksums.txt does not exist", () => {
+ it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
- // No checksums.txt in dir
- const result = getExpectedChecksum("anything.tar.gz", dir);
- assert.equal(result, null);
+ assert.throws(
+ () => getExpectedChecksum("anything.tar.gz", dir),
+ { message: /^\[SECURITY\] checksums\.txt not found/ }
+ );
});
it("skips malformed lines and still finds valid entry", () => {
@@ -106,7 +107,7 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
- it("matches case-insensitively", () => {
+ it("accepts a valid uppercase 64-character hex hash", () => {
const content = "case test";
const filePath = makeTmpFile(content);
const hash = sha256(content).toUpperCase();
@@ -114,6 +115,40 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
+ for (const [name, expectedHash] of [
+ ["null", null],
+ ["empty", ""],
+ ["non-string", 123],
+ ]) {
+ it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
+ const filePath = makeTmpFile("real content");
+ assert.throws(
+ () => verifyChecksum(filePath, expectedHash),
+ (err) => {
+ assert.match(err.message, /^\[SECURITY\]/);
+ assert.match(err.message, /Expected checksum is missing or invalid/);
+ return true;
+ }
+ );
+ });
+ }
+
+ it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
+ const filePath = makeTmpFile("real content");
+ assert.throws(
+ () => verifyChecksum(filePath, "abc123"),
+ { message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
+ );
+ });
+
+ it("throws [SECURITY] format Error for a non-hex hash", () => {
+ const filePath = makeTmpFile("real content");
+ assert.throws(
+ () => verifyChecksum(filePath, "g".repeat(64)),
+ { message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
+ );
+ });
+
it("throws [SECURITY]-prefixed Error on mismatch", () => {
const filePath = makeTmpFile("real content");
assert.throws(
diff --git a/scripts/release-preflight.js b/scripts/release-preflight.js
new file mode 100644
index 000000000..6de81a580
--- /dev/null
+++ b/scripts/release-preflight.js
@@ -0,0 +1,108 @@
+#!/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 STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
+
+function isStableVersion(value) {
+ return typeof value === "string" && STABLE_VERSION_PATTERN.test(value);
+}
+
+function releaseError(message, observed, hint) {
+ return { ok: false, error: { type: "release_preflight", message, observed, hint } };
+}
+
+function validateReleasePreflight(packageJson, packageLockJson, tag) {
+ const packageVersion = packageJson?.version;
+ const lockVersion = packageLockJson?.version;
+ const lockRootVersion = packageLockJson?.packages?.[""]?.version;
+ const observed = {
+ packageVersion: packageVersion ?? null,
+ lockVersion: lockVersion ?? null,
+ lockRootVersion: lockRootVersion ?? null,
+ tagVersion: null,
+ };
+
+ for (const [field, value] of [
+ ["package.json.version", packageVersion],
+ ["package-lock.json.version", lockVersion],
+ ['package-lock.json.packages[""].version', lockRootVersion],
+ ]) {
+ if (!isStableVersion(value)) {
+ return releaseError(
+ `${field} must be a stable release version in X.Y.Z form`,
+ observed,
+ "Use the same stable X.Y.Z version in all package fields; prerelease and build metadata are not allowed for production releases.",
+ );
+ }
+ }
+
+ if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
+ return releaseError(
+ "Package version fields do not match",
+ observed,
+ "Synchronize package.json.version and both package-lock.json version fields.",
+ );
+ }
+
+ if (tag === undefined) {
+ return { ok: true, data: observed };
+ }
+ if (typeof tag !== "string" || !tag.startsWith("v") || !isStableVersion(tag.slice(1))) {
+ return releaseError(
+ "--tag must use the stable release form vX.Y.Z",
+ { ...observed, tag },
+ `Use --tag v${packageVersion}; prerelease and build metadata are not allowed for production releases.`,
+ );
+ }
+
+ const tagVersion = tag.slice(1);
+ if (tagVersion !== packageVersion) {
+ return releaseError(
+ "Tag version does not match the package version",
+ { ...observed, tagVersion, tag },
+ `Use --tag v${packageVersion}.`,
+ );
+ }
+ return { ok: true, data: { ...observed, tagVersion } };
+}
+
+function writeResult(result) {
+ (result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
+ if (!result.ok) process.exitCode = 1;
+}
+
+function main() {
+ const args = process.argv.slice(2);
+ let tag;
+ if (args.length === 2 && args[0] === "--tag") {
+ tag = args[1];
+ } else if (args.length !== 0) {
+ writeResult(releaseError(
+ "Expected no arguments or --tag vX.Y.Z",
+ { arguments: args },
+ "Run release:check without arguments or pass exactly one --tag value.",
+ ));
+ return;
+ }
+
+ const repoRoot = path.resolve(__dirname, "..");
+ try {
+ const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
+ const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
+ writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
+ } catch (error) {
+ writeResult(releaseError(
+ "Could not read release package metadata",
+ { reason: error.message },
+ "Ensure package.json and package-lock.json exist and contain valid JSON.",
+ ));
+ }
+}
+
+module.exports = { validateReleasePreflight };
+
+if (require.main === module) main();
diff --git a/scripts/release-preflight.test.js b/scripts/release-preflight.test.js
new file mode 100644
index 000000000..da767094f
--- /dev/null
+++ b/scripts/release-preflight.test.js
@@ -0,0 +1,66 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+const assert = require("node:assert/strict");
+const { describe, it } = require("node:test");
+
+const { validateReleasePreflight } = require("./release-preflight");
+
+function metadata(version = "1.2.3") {
+ return {
+ packageJson: { version },
+ packageLockJson: {
+ version,
+ packages: { "": { version } },
+ },
+ };
+}
+
+function assertRejected(result) {
+ assert.equal(result.ok, false);
+ assert.equal(result.error.type, "release_preflight");
+ assert.equal(typeof result.error.message, "string");
+}
+
+describe("validateReleasePreflight", () => {
+ it("accepts matching stable package, lock, and tag versions", () => {
+ const { packageJson, packageLockJson } = metadata();
+
+ assert.deepEqual(
+ validateReleasePreflight(packageJson, packageLockJson, "v1.2.3"),
+ {
+ ok: true,
+ data: {
+ packageVersion: "1.2.3",
+ lockVersion: "1.2.3",
+ lockRootVersion: "1.2.3",
+ tagVersion: "1.2.3",
+ },
+ },
+ );
+ });
+
+ it("rejects non-stable or inconsistent package metadata", () => {
+ const prerelease = metadata("1.2.3-beta.1");
+ const topLevelMismatch = metadata();
+ topLevelMismatch.packageLockJson.version = "1.2.4";
+ const rootMismatch = metadata();
+ rootMismatch.packageLockJson.packages[""].version = "1.2.4";
+
+ for (const { packageJson, packageLockJson } of [
+ prerelease,
+ topLevelMismatch,
+ rootMismatch,
+ ]) {
+ assertRejected(validateReleasePreflight(packageJson, packageLockJson));
+ }
+ });
+
+ it("rejects an invalid or mismatched release tag", () => {
+ const { packageJson, packageLockJson } = metadata();
+
+ for (const tag of ["1.2.3", "v1.2.3-beta.1", "v1.2.4"]) {
+ assertRejected(validateReleasePreflight(packageJson, packageLockJson, tag));
+ }
+ });
+});
diff --git a/scripts/semantic-review-workflow.test.sh b/scripts/semantic-review-workflow.test.sh
index 9e664ce69..2b5e118af 100644
--- a/scripts/semantic-review-workflow.test.sh
+++ b/scripts/semantic-review-workflow.test.sh
@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
exit 1
fi
-require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
+if grep -Fq 'run.name !== "CI"' "$workflow"; then
+ echo "semantic-review must not use the dynamic workflow run name as workflow identity" >&2
+ exit 1
+fi
+
+require_in_step "$summary_verify_step" 'github.rest.actions.getWorkflow' "PR quality summary must resolve static workflow metadata"
+require_in_step "$summary_verify_step" 'workflow.name !== "CI"' "PR quality summary must verify the static workflow name"
+require_in_step "$summary_verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "PR quality summary must verify the static workflow path"
+require_in_step "$summary_verify_step" 'run.path && run.path !== workflow.path' "PR quality summary must reject workflow path metadata mismatches"
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
@@ -201,7 +209,10 @@ require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR qualit
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
-require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
+require_in_step "$verify_step" 'github.rest.actions.getWorkflow' "semantic-review must resolve static workflow metadata"
+require_in_step "$verify_step" 'workflow.name !== "CI"' "semantic-review must verify the static workflow name"
+require_in_step "$verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "semantic-review must verify the static workflow path"
+require_in_step "$verify_step" 'run.path && run.path !== workflow.path' "semantic-review must reject workflow path metadata mismatches"
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
diff --git a/scripts/tag-release.sh b/scripts/tag-release.sh
index c3b486f91..cec0a51af 100755
--- a/scripts/tag-release.sh
+++ b/scripts/tag-release.sh
@@ -3,49 +3,48 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+cd "${REPO_ROOT}"
-# Read version from package.json
-VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
-
-if [ -z "$VERSION" ]; then
- echo "Error: could not read version from package.json" >&2
- exit 1
-fi
-
+VERSION=$(node -p "require('./package.json').version")
TAG="v${VERSION}"
+node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
+
echo "Version: ${VERSION}"
echo "Tag: ${TAG}"
-# Check if tag already exists locally
-if git rev-parse "$TAG" >/dev/null 2>&1; then
- echo "Tag ${TAG} already exists locally, skipping."
- exit 0
-fi
-
-# Check if tag already exists on remote
-if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
- echo "Tag ${TAG} already exists on remote, skipping."
- exit 0
-fi
-
-# Ensure package.json changes are committed before tagging
-if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
- echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
+CURRENT_BRANCH=$(git branch --show-current)
+if [ "${CURRENT_BRANCH}" != "main" ]; then
+ echo "Error: releases must be tagged from main; current branch is '${CURRENT_BRANCH}'." >&2
exit 1
fi
-# Ensure current branch is pushed to remote before tagging
-CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
-LOCAL_SHA=$(git rev-parse HEAD)
-REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
-if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
- echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
+if ! git diff --quiet HEAD -- package.json package-lock.json; then
+ echo "Error: package.json or package-lock.json has uncommitted changes. Please commit them before tagging." >&2
exit 1
fi
-# Create and push tag
-git tag "$TAG"
-git push origin "$TAG"
+git fetch origin main
-echo "Successfully created and pushed tag ${TAG}"
+HEAD_SHA=$(git rev-parse HEAD)
+FETCHED_MAIN_SHA=$(git rev-parse "FETCH_HEAD^{commit}")
+if [ "${HEAD_SHA}" != "${FETCHED_MAIN_SHA}" ]; then
+ echo "Error: HEAD must exactly match origin/main before tagging." >&2
+ exit 1
+fi
+
+if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
+ echo "Error: local tag ${TAG} already exists." >&2
+ exit 1
+fi
+
+REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/${TAG}")
+if [ -n "${REMOTE_TAG}" ]; then
+ echo "Error: remote tag ${TAG} already exists." >&2
+ exit 1
+fi
+
+git tag "${TAG}" "${HEAD_SHA}"
+git push origin "refs/tags/${TAG}"
+
+echo "Successfully pushed tag ${TAG}"
diff --git a/shortcuts/apps/apps_automation_skill_contract_test.go b/shortcuts/apps/apps_automation_skill_contract_test.go
new file mode 100644
index 000000000..dbe1803f7
--- /dev/null
+++ b/shortcuts/apps/apps_automation_skill_contract_test.go
@@ -0,0 +1,469 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package apps
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+const automationSkillDoc = "../../skills/lark-apps/references/lark-apps-automation.md"
+const localDevSkillDoc = "../../skills/lark-apps/references/lark-apps-local-dev.md"
+const larkAppsSkillDoc = "../../skills/lark-apps/SKILL.md"
+const releaseGetSkillDoc = "../../skills/lark-apps/references/lark-apps-release-get.md"
+
+func readAutomationSkillDoc(t *testing.T) string {
+ return readAppsSkillDoc(t, automationSkillDoc)
+}
+
+func readLocalDevSkillDoc(t *testing.T) string {
+ return readAppsSkillDoc(t, localDevSkillDoc)
+}
+
+func readReleaseGetSkillDoc(t *testing.T) string {
+ return readAppsSkillDoc(t, releaseGetSkillDoc)
+}
+
+func readAppsSkillDoc(t *testing.T, path string) string {
+ t.Helper()
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read skill doc %s: %v", path, err)
+ }
+ return string(raw)
+}
+
+func skillSection(t *testing.T, doc, heading string) string {
+ t.Helper()
+ start := strings.Index(doc, heading)
+ if start < 0 {
+ t.Fatalf("missing skill section %q", heading)
+ }
+ rest := doc[start+len(heading):]
+ if next := strings.Index(rest, "\n## "); next >= 0 {
+ return rest[:next]
+ }
+ return rest
+}
+
+func skillSubsection(t *testing.T, doc, heading string) string {
+ t.Helper()
+ start := strings.Index(doc, heading)
+ if start < 0 {
+ t.Fatalf("missing skill subsection %q", heading)
+ }
+ rest := doc[start+len(heading):]
+ end := len(rest)
+ for _, marker := range []string{"\n### ", "\n## "} {
+ if next := strings.Index(rest, marker); next >= 0 && next < end {
+ end = next
+ }
+ }
+ return rest[:end]
+}
+
+func requireInOrder(t *testing.T, text string, tokens ...string) {
+ t.Helper()
+ offset := 0
+ for _, token := range tokens {
+ idx := strings.Index(text[offset:], token)
+ if idx < 0 {
+ t.Fatalf("missing %q after %q", token, text[:offset])
+ }
+ offset += idx + len(token)
+ }
+}
+
+func requireFirstOccurrencesInOrder(t *testing.T, text string, tokens ...string) {
+ t.Helper()
+ previous := -1
+ for _, token := range tokens {
+ idx := strings.Index(text, token)
+ if idx < 0 {
+ t.Fatalf("missing %q", token)
+ }
+ if idx <= previous {
+ t.Fatalf("first %q at %d must follow the previous contract token at %d", token, idx, previous)
+ }
+ previous = idx
+ }
+}
+
+func TestAutomationSkillContract_ChangedHandlerStartWaitsForThisRelease(t *testing.T) {
+ section := skillSubsection(t, readAutomationSkillDoc(t), "### 实现或更新 handler 后发布并启动/测试")
+
+ requireInOrder(t, section,
+ "仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler",
+ "+automation-get",
+ "记录发布前状态",
+ "--name",
+ "项目 guide",
+ "按项目 guide 完成同名业务 handler 并本地验证。",
+ "在 Git 已确认/预授权时 commit,然后执行",
+ "git push origin sprint/default",
+ "临时停用授权",
+ "+automation-disable",
+ "确认 disabled",
+ "+release-create --branch sprint/default",
+ "data.release_id",
+ "+release-get",
+ "data.status=finished",
+ "仅启动",
+ "+automation-enable",
+ "+automation-get",
+ "不制造 runtime probe",
+ "测试",
+ "运行时验证的操作级授权",
+ "完成全部 preflight",
+ "才执行 `+automation-enable`",
+ "真实 runtime",
+ "仅要求测试",
+ "恢复到发布前状态",
+ )
+ requireFirstOccurrencesInOrder(t, section,
+ "+automation-get",
+ "git push origin sprint/default",
+ "临时停用授权",
+ "+automation-disable",
+ "+release-create --branch sprint/default",
+ "data.status=finished",
+ "仅启动",
+ )
+ for _, boundary := range []string{
+ "仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler,且用户要求把这次代码发布后启动或测试时,才使用此路径。",
+ "按项目 guide 完成同名业务 handler 并本地验证。",
+ "在 Git 已确认/预授权时 commit,然后执行 `git push origin sprint/default`。",
+ "若该命令本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release(新代码未上线),原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled 后停止;若因超时等导致结果未知,保持 disabled,先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
+ "只有 `data.status=finished` 才能继续;`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟。",
+ "确认 `failed` 时报告发布失败,原本 enabled 的 trigger 仅在确认新代码未上线后恢复 enabled,原本 disabled 的保持 disabled。",
+ "发布状态仍不确定时不得进入 enable、probe 或状态恢复分支。",
+ "**仅启动**:取得持续启动授权后执行 `+automation-enable`,并用 `+automation-get` 确认 enabled;到此结束,不制造 runtime probe。",
+ "**测试(含“启动并测试”)**:先按下节“运行时验证的操作级授权”完成全部 preflight",
+ "若用户仅要求测试而不是持续启动,只在本轮 release 已 `finished` 且 probe 成功后恢复到发布前状态",
+ "无论用户是仅测试还是启动并测试,probe 失败、结果不确定或 enable 后提前结束时,一律 `+automation-disable` 并回读 disabled",
+ "不得把“发布前 enabled”当作失败后的恢复依据",
+ "没有通用的 `automation-debug` 或 trigger 日志 shortcut。",
+ } {
+ if !strings.Contains(section, boundary) {
+ t.Errorf("complete-start section must explain %q boundary", boundary)
+ }
+ }
+}
+
+func TestAutomationSkillContract_BindsTheExactNameAsUser(t *testing.T) {
+ doc := readAutomationSkillDoc(t)
+ for _, boundary := range []string{
+ "全部操作需 `--as user`(AuthType: user)。",
+ "当用户希望触发器实际执行业务代码时,先确认当前工作区是已初始化的应用项目,并读取其中与触发器任务匹配的 guide。",
+ "`--name` 是应用内唯一的 trigger 定位键;代码侧绑定名称必须与它逐字相同。不得用 trigger ID 或方法名代替它。具体 handler 语法和接入方式以项目 guide 为准。",
+ } {
+ if !strings.Contains(doc, boundary) {
+ t.Errorf("automation skill must preserve %q", boundary)
+ }
+ }
+}
+
+func TestAutomationSkillContract_RoutesAndDiagnosesUnfiredTriggers(t *testing.T) {
+ doc := readAutomationSkillDoc(t)
+ routeSection := skillSection(t, doc, "## 何时用本 skill(路由锚点)")
+ errorSection := skillSection(t, doc, "## 常见错误与决策场景")
+
+ if !strings.Contains(routeSection, "「触发器没反应 / enable 了不触发 / 为什么没执行 / 验证一下触发器」→ 先按「未触发时的诊断顺序」诊断;对 UPSERT 和 feishu-approval 仅验证配置边界,不承诺 handler 或 live 验证。") {
+ t.Error("routing anchors must direct unfired triggers to the bounded diagnostic flow")
+ }
+ if !strings.Contains(errorSection, "已证实的 cron、webhook、record-change(INSERT/UPDATE/DELETE)按「未触发时的诊断顺序」排查;UPSERT 和 feishu-approval 仅核对配置边界,不承诺 handler 或 live 验证。") {
+ t.Error("error table must preserve the bounded unfired-trigger diagnostic flow")
+ }
+}
+
+func TestAutomationSkillContract_ConfigurationStopsDisabled(t *testing.T) {
+ section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅创建/配置触发器")
+
+ for _, boundary := range []string{
+ "用 `+automation-create` 创建,并省略 `--status` 或显式传 `disabled`,然后报告 name 和 disabled 状态。",
+ "不要传 `--status enabled`,也不要写 handler、commit/push、release 或 enable;更不能把创建 API 成功称为“可运行”。",
+ "默认 disabled 是这个意图的终点,不是稍后自动 enable 的待办。",
+ } {
+ if !strings.Contains(section, boundary) {
+ t.Errorf("configuration-only section must preserve %q", boundary)
+ }
+ }
+}
+
+func TestAutomationSkillContract_EnableExistingTriggerDoesNotPublish(t *testing.T) {
+ doc := readAutomationSkillDoc(t)
+ section := skillSubsection(t, doc, "### 仅启用已有 disabled trigger")
+ routeSection := skillSection(t, doc, "## 何时用本 skill(路由锚点)")
+
+ requireInOrder(t, section,
+ "用户只要求启用已存在且 disabled 的 trigger",
+ "+automation-get",
+ "+release-list --status finished --page-size 1",
+ "已完成线上 release",
+ "当前线上应用",
+ "不能证明该 trigger name 已绑定 handler",
+ "+automation-enable",
+ "+automation-get",
+ "不得修改 handler、commit/push 或 release",
+ "对 UPSERT 或 feishu-approval 只改变配置状态",
+ )
+ if !strings.Contains(section, "未发布时不得自动创建 release,也不得声称 trigger 已开始实际运行") {
+ t.Error("enable-only flow must distinguish configuration enablement from a published runtime")
+ }
+ if !strings.Contains(section, "即使存在 finished release,也只能把 enable 报告为配置激活") {
+ t.Error("enable-only flow must not infer handler provenance from app release history")
+ }
+ if strings.Contains(section, "apps +get") || strings.Contains(section, "`is_published`") {
+ t.Error("enable-only flow must use finished release history instead of an optional app detail field")
+ }
+ for _, forbidden := range []string{"git push", "+release-create"} {
+ if strings.Contains(section, forbidden) {
+ t.Errorf("enable-only flow must not contain %q", forbidden)
+ }
+ }
+ if !strings.Contains(routeSection, "「启用 / 启动已有 trigger」→ 先核对现有状态;只启用时不要修改源码或发布应用。") {
+ t.Error("routing anchors must keep existing-trigger enablement separate from code release")
+ }
+}
+
+func TestAutomationSkillContract_TestExistingTriggerDoesNotPublish(t *testing.T) {
+ section := skillSubsection(t, readAutomationSkillDoc(t), "### 测试已有线上 trigger(不改代码)")
+
+ requireInOrder(t, section,
+ "用户要求测试已经发布的 trigger",
+ "+automation-get",
+ "+release-list --status finished --page-size 1",
+ "当前线上代码",
+ "不得为测试自动修改源码、commit/push 或 release",
+ "在任何临时 enable 之前完成",
+ "测试请求已明确包含临时 enable,或另行取得 enable 授权",
+ "运行时验证的操作级授权",
+ "无论 probe 成功、失败、结果不确定,还是临时 enable 后提前结束或中断,最终都必须 `+automation-disable` 并回读 disabled",
+ )
+ for _, forbidden := range []string{"git push", "+release-create"} {
+ if strings.Contains(section, forbidden) {
+ t.Errorf("existing-trigger test flow must not contain %q", forbidden)
+ }
+ }
+}
+
+func TestAutomationSkillContract_HandlerOnlyStopsBeforeRelease(t *testing.T) {
+ section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler(不发布/不启用)")
+
+ for _, boundary := range []string{
+ "创建或定位已明确 name 的 disabled trigger,读取项目 guide,按其要求实现同名业务 handler,完成本地验证。",
+ "只在既有 Git 确认或预授权下 commit/push;停止在 `+release-create` 和 `+automation-enable` 之前。",
+ "用户没有明确“发布好”时,先问,不能默认把完整应用上线。",
+ } {
+ if !strings.Contains(section, boundary) {
+ t.Errorf("handler-only section must preserve %q", boundary)
+ }
+ }
+}
+
+func TestAutomationSkillContract_HandlerOnlyExcludesUnverifiedRuntimeTypes(t *testing.T) {
+ section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler(不发布/不启用)")
+
+ if !strings.Contains(section, "仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。") {
+ t.Error("handler-only flow must exclude UPSERT and feishu-approval without a verified runtime contract")
+ }
+}
+
+func TestAutomationSkillContract_PublishedHandlerStaysDisabled(t *testing.T) {
+ section := skillSubsection(t, readAutomationSkillDoc(t), "### 把 handler 发布好,但先不要启动")
+
+ for _, boundary := range []string{
+ "仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。",
+ "先用 `+automation-get` 定位;不存在时用 `+automation-create` 创建同名 disabled trigger,再次回读确认。",
+ "已存在时记录它是否 enabled。",
+ "若 trigger 已 enabled,先说明发布前必须临时停用以及可能造成的运行中断,并取得这次临时停用授权;未获授权时停止在发布前。",
+ "取得授权后,在发布前执行 `+automation-disable`,并再次用 `+automation-get` 确认 disabled。",
+ "按项目 guide 完成同名业务 handler 并本地验证后,commit、`git push origin sprint/default`。",
+ "随后发布完整应用:",
+ "若 `+release-create` 本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release(新代码未上线),原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled,然后停止;若因超时等导致创建结果未知,保持 disabled,先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
+ "取得 `data.release_id` 后,对**这一轮** ID 调用 `+release-get`:`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时且状态仍不确定时报告 `release_id` 和当前 status,并保持 disabled;只有 `data.status=finished` 才算完成。",
+ "确认 `failed` 且新代码未上线时,原本 enabled 的 trigger 恢复 enabled 并回读,原本 disabled 的保持 disabled。",
+ "release 是整个应用上线,可能影响既有线上功能;未获得启动或测试授权时,finished 后始终保持 disabled,不执行 `+automation-enable`。",
+ } {
+ if !strings.Contains(section, boundary) {
+ t.Errorf("publish-without-start section must preserve %q", boundary)
+ }
+ }
+ requireFirstOccurrencesInOrder(t, section,
+ "+automation-get",
+ "git push origin sprint/default",
+ "临时停用授权",
+ "+automation-disable",
+ "+release-create",
+ )
+}
+
+func TestAutomationSkillContract_UPSERTAndApprovalStayConfigurationOnly(t *testing.T) {
+ section := skillSubsection(t, readAutomationSkillDoc(t), "### UPSERT 与飞书审批边界")
+
+ for _, boundary := range []string{
+ "record-change 的 UPSERT 可创建 disabled 配置,但当前没有已证实的运行时代码契约;不得静默按 UPDATE 处理,也不得承诺 handler 或 live 验证。",
+ "feishu-approval 可创建 disabled 配置,并读取或更新 `event_type`、对应 status 和可选 `approval_code`。",
+ "当前没有已证实的运行时 handler 契约或实际投递验证;不要把 enable 或审批 API 成功称为业务代码已执行。",
+ } {
+ if !strings.Contains(section, boundary) {
+ t.Errorf("UPSERT/approval boundary section must preserve %q", boundary)
+ }
+ }
+}
+
+func TestAutomationSkillContract_RuntimeProbeRequiresOperationScope(t *testing.T) {
+ section := skillSubsection(t, readAutomationSkillDoc(t), "### 运行时验证的操作级授权")
+
+ for _, boundary := range []string{
+ "启用 trigger 的授权不等于制造 runtime 事件的授权,测试授权也不等于任意数据库写入授权。",
+ "record-change 在执行任何 DML 前,必须明确并取得覆盖以下作用域的授权",
+ "环境、表、操作、精确测试记录或筛选条件、payload、预期结果和清理方式",
+ "优先使用专用测试记录",
+ "`DELETE`",
+ "[lark-apps-db-execute.md](lark-apps-db-execute.md)",
+ "先 `SELECT count(*)`、执行 `--dry-run`",
+ "取得针对该删除目标的明确授权",
+ "+automation-list --trigger-type record-change --all",
+ "同一环境、表和操作可能命中的其他 enabled trigger",
+ "聚合业务影响",
+ "恢复 UPDATE 或清理 INSERT 也可能再次触发自动化",
+ "缺少安全、已授权且可清理的事件入口时,记录 blocked",
+ } {
+ if !strings.Contains(section, boundary) {
+ t.Errorf("runtime probe section must preserve %q", boundary)
+ }
+ }
+}
+
+func TestAutomationSkillContract_UsesResolvableSharedSkillLink(t *testing.T) {
+ doc := readAutomationSkillDoc(t)
+
+ if strings.Contains(doc, "](../lark-shared/SKILL.md)") {
+ t.Error("automation reference must not resolve lark-shared inside the lark-apps directory")
+ }
+ if !strings.Contains(doc, "](../../lark-shared/SKILL.md)") {
+ t.Error("automation reference must link to the sibling lark-shared skill")
+ }
+ sharedSkillDoc := filepath.Clean(filepath.Join(filepath.Dir(automationSkillDoc), "../../lark-shared/SKILL.md"))
+ if _, err := os.Stat(sharedSkillDoc); err != nil {
+ t.Fatalf("automation reference target %s must exist: %v", sharedSkillDoc, err)
+ }
+}
+
+func TestAppsSkillContract_AllSharedSkillLinksResolve(t *testing.T) {
+ docs := []string{larkAppsSkillDoc}
+ references, err := filepath.Glob("../../skills/lark-apps/references/*.md")
+ if err != nil {
+ t.Fatalf("glob lark-apps references: %v", err)
+ }
+ docs = append(docs, references...)
+ sharedLink := regexp.MustCompile(`\]\(([^)]+lark-shared/SKILL\.md)\)`)
+
+ for _, docPath := range docs {
+ doc := readAppsSkillDoc(t, docPath)
+ for _, match := range sharedLink.FindAllStringSubmatch(doc, -1) {
+ target := filepath.Clean(filepath.Join(filepath.Dir(docPath), match[1]))
+ if _, err := os.Stat(target); err != nil {
+ t.Errorf("%s shared-skill link %q resolves to missing target %s: %v", docPath, match[1], target, err)
+ }
+ }
+ }
+}
+
+func TestLocalDevSkillContract_UsesProjectGuideWithoutSyncInternals(t *testing.T) {
+ section := skillSection(t, readLocalDevSkillDoc(t), "## Trigger guide 的项目边界")
+
+ for _, boundary := range []string{
+ "先查看工作区 `.agents/skills/`,读取与自动化任务匹配的 `trigger-guide`。",
+ "文件缺失或不能覆盖当前任务时,报告项目缺少可用的领域 guide;不要在本 lark-cli reference 中猜测安装命令、版本或包内目录。",
+ } {
+ if !strings.Contains(section, boundary) {
+ t.Errorf("trigger-guide boundary section must explain %q", boundary)
+ }
+ }
+ for _, implementationShape := range []string{
+ "npx ", "skills sync", "data.", "skills_", "_CACHE_DIR", "nestjs-",
+ "@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
+ } {
+ if strings.Contains(section, implementationShape) {
+ t.Errorf("local-dev skill must not expose project-sync implementation shape %q", implementationShape)
+ }
+ }
+}
+
+func TestAppsSkillContract_DoesNotExposeSteeringImplementation(t *testing.T) {
+ for name, doc := range map[string]string{
+ "automation": readAutomationSkillDoc(t),
+ "local-dev": readLocalDevSkillDoc(t),
+ } {
+ for _, implementationShape := range []string{
+ "npx ", "skills sync", "@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
+ } {
+ if strings.Contains(doc, implementationShape) {
+ t.Errorf("%s skill must not expose project-sync implementation shape %q", name, implementationShape)
+ }
+ }
+ }
+}
+
+func TestLocalDevSkillContract_UsesEnvironmentAndDefersEnableToAutomationSOP(t *testing.T) {
+ doc := readLocalDevSkillDoc(t)
+ releaseSection := skillSection(t, doc, "## 改完代码后部署上线")
+ for _, legacy := range []string{"--env dev", "--env online"} {
+ if strings.Contains(doc, legacy) {
+ t.Errorf("local-dev skill must not recommend legacy %q", legacy)
+ }
+ }
+ for _, boundary := range []string{
+ "`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时仍未完成时停止本轮轮询、报告 `release_id` 和当前 status。",
+ "若本次改动包含自动化 handler,在执行本节通用 commit/push/release 序列前就转到 [automation SOP](lark-apps-automation.md) 的匹配路径,由该 SOP 负责完整的状态门禁、commit/push、release 和可选 enable/test;不要先按本节发布再补 trigger 状态检查。",
+ "用户只要求启用已有 trigger 时,转到 [automation SOP 的「仅启用已有 disabled trigger」路径](lark-apps-automation.md#仅启用已有-disabled-trigger);不得因 enable 反向修改 handler、commit/push 或 release。",
+ "使用 `--environment dev|online`,不要使用旧的 `--env`。只有确认应用已开启多环境时才引导 `--environment dev`;单环境应用省略 `--environment`(服务端选 online)或显式传 `--environment online`。",
+ } {
+ if !strings.Contains(doc, boundary) {
+ t.Errorf("local-dev skill must preserve %q", boundary)
+ }
+ }
+ routeIndex := strings.Index(releaseSection, "若本次改动包含自动化 handler")
+ releaseIndex := strings.Index(releaseSection, "+release-create")
+ if routeIndex < 0 || releaseIndex < 0 || routeIndex >= releaseIndex {
+ t.Error("automation routing must appear before the generic release sequence")
+ }
+}
+
+func TestLocalDevSkillContract_DoesNotRequireOnlineURL(t *testing.T) {
+ section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
+
+ if strings.Contains(section, "`finished` 成功时该命令输出已含 `online_url`") {
+ t.Error("release guidance must not claim every finished release includes online_url")
+ }
+ if !strings.Contains(section, "若返回 `online_url`,可直接使用;未返回时不要编造链接。") {
+ t.Error("release guidance must explain that online_url is optional")
+ }
+}
+
+func TestLocalDevSkillContract_TreatsErrorLogsAsOptional(t *testing.T) {
+ section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
+
+ if !strings.Contains(section, "`failed` 时若返回非空 `error_logs`,据此给出失败原因;否则只报告 `release_id` 和当前 status,不要编造原因") {
+ t.Error("release guidance must not promise error_logs on every failed release")
+ }
+}
+
+func TestReleaseSkillContract_TreatsOptionalOutputAsOptional(t *testing.T) {
+ releaseGet := readReleaseGetSkillDoc(t)
+ for _, boundary := range []string{
+ "`finished` 后才可能有 `online_url`。",
+ "若输出含 `online_url`,直接读取它作为本轮发布的线上访问链接;未返回时只报告发布完成,不要编造链接。",
+ "若输出含 `error_logs`(`step`/`error_log`),据此向用户转述关键失败步骤和可行动修复;未返回时不要编造失败原因。",
+ } {
+ if !strings.Contains(releaseGet, boundary) {
+ t.Errorf("release-get skill must preserve optional-output boundary %q", boundary)
+ }
+ }
+}
diff --git a/shortcuts/apps/apps_errors.go b/shortcuts/apps/apps_errors.go
index 341345c53..a00998e1f 100644
--- a/shortcuts/apps/apps_errors.go
+++ b/shortcuts/apps/apps_errors.go
@@ -8,7 +8,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
- "github.com/larksuite/cli/internal/client"
)
func appsValidationError(format string, args ...any) *errs.ValidationError {
@@ -74,32 +73,3 @@ func appsInputPathEntryError(path string, err error) error {
func appsFileIOError(err error, format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
}
-
-// enrichHTMLPublishAPIError adapts a typed failure from the HTML publish
-// endpoint: refines endpoint-scoped business codes, prefixes the message with
-// command context, and attaches endpoint-specific recovery hints. A
-// still-untyped error is lifted at the SDK boundary instead.
-func enrichHTMLPublishAPIError(err error) error {
- if err == nil {
- return nil
- }
- p, ok := errs.ProblemOf(err)
- if !ok {
- return client.WrapDoAPIError(err)
- }
- // The HTML publish business codes (90001/90002) are scoped to this
- // endpoint, not service-global, so their subtype classification lives
- // here instead of the global errclass code table. Only an
- // otherwise-unclassified API error is refined; a stronger upstream
- // classification is never overridden.
- if p.Category == errs.CategoryAPI && p.Subtype == errs.SubtypeUnknown && p.Code == errCodeAppNotFound {
- p.Subtype = errs.SubtypeNotFound
- }
- if p.Message != "" {
- p.Message = "html-publish failed: " + p.Message
- }
- if hint := buildHTMLPublishFailureHint(p.Code); hint != "" {
- p.Hint = hint
- }
- return err
-}
diff --git a/shortcuts/apps/apps_errors_test.go b/shortcuts/apps/apps_errors_test.go
index ccab1d7cb..1c8dcc03c 100644
--- a/shortcuts/apps/apps_errors_test.go
+++ b/shortcuts/apps/apps_errors_test.go
@@ -57,57 +57,3 @@ func TestAppsFileIOError_ClassifiesInternalFileIO(t *testing.T) {
t.Fatalf("cause chain not preserved: %v", err)
}
}
-
-func TestEnrichHTMLPublishAPIError_LiftsUntypedBoundaryError(t *testing.T) {
- err := enrichHTMLPublishAPIError(errors.New("connection reset by peer"))
-
- problem := requireAppsProblem(t, err, errs.CategoryNetwork)
- if problem.Subtype != errs.SubtypeNetworkTransport {
- t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNetworkTransport)
- }
-}
-
-func TestEnrichHTMLPublishAPIError_PreservesClassificationAndAddsHint(t *testing.T) {
- err := errs.NewAPIError(errs.SubtypeUnknown, "build failed").
- WithCode(errCodeBuildFailed).
- WithLogID("logid-build-failed")
-
- got := enrichHTMLPublishAPIError(err)
- if got != err {
- t.Fatalf("typed error should be enriched in place")
- }
- problem := requireAppsAPIProblem(t, got)
- if problem.Subtype != errs.SubtypeUnknown {
- t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeUnknown)
- }
- if problem.Code != errCodeBuildFailed {
- t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
- }
- if problem.LogID != "logid-build-failed" {
- t.Fatalf("log_id = %q, want preserved", problem.LogID)
- }
- if !strings.Contains(problem.Message, "html-publish failed") {
- t.Fatalf("message = %q, want html-publish context", problem.Message)
- }
- if problem.Hint == "" {
- t.Fatalf("expected known-code recovery hint")
- }
-}
-
-func TestEnrichHTMLPublishAPIError_ClassifiesAppNotFoundLocally(t *testing.T) {
- err := errs.NewAPIError(errs.SubtypeUnknown, "app not found").WithCode(errCodeAppNotFound)
-
- problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
- if problem.Subtype != errs.SubtypeNotFound {
- t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
- }
-}
-
-func TestEnrichHTMLPublishAPIError_KeepsStrongerClassification(t *testing.T) {
- err := errs.NewAPIError(errs.SubtypeRateLimit, "throttled").WithCode(errCodeAppNotFound)
-
- problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
- if problem.Subtype != errs.SubtypeRateLimit {
- t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeRateLimit)
- }
-}
diff --git a/shortcuts/apps/apps_file_list.go b/shortcuts/apps/apps_file_list.go
index 251d4a257..36050dc55 100644
--- a/shortcuts/apps/apps_file_list.go
+++ b/shortcuts/apps/apps_file_list.go
@@ -12,10 +12,23 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
+// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
+// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
+// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
+const maxFileListPageSize = 200
+
+// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
+func validateFileListPageSize(n int) error {
+ if n < 1 || n > maxFileListPageSize {
+ return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
+ }
+ return nil
+}
+
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
//
// GET /apps/{app_id}/storage/file_list。过滤器:--name / --path / --type / --size-gt /
-// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size/--page-token。
+// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size(1..200)/--page-token。
// file 域不分 dev/online,无 --env。
//
// pretty 渲染 5 列:file_name / path / size / type / uploaded_at;空结果打 "No files found."。
@@ -41,13 +54,17 @@ var AppsFileList = common.Shortcut{
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
- {Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
+ {Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
+ // page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
+ if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
+ return err
+ }
// 设计原则三: 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。
for _, f := range []string{"uploaded-since", "uploaded-until"} {
if strings.TrimSpace(rctx.Str(f)) == "" {
diff --git a/shortcuts/apps/apps_file_list_test.go b/shortcuts/apps/apps_file_list_test.go
index dcfdd3d12..102236d6d 100644
--- a/shortcuts/apps/apps_file_list_test.go
+++ b/shortcuts/apps/apps_file_list_test.go
@@ -82,6 +82,34 @@ func TestAppsFileList_RequiresAppID(t *testing.T) {
}
}
+// TestAppsFileList_PageSizeOutOfRange 验证 --page-size 超出 (0, 200] 契约时前置报 --page-size 校验错误,不发请求。
+func TestAppsFileList_PageSizeOutOfRange(t *testing.T) {
+ for _, ps := range []string{"0", "201", "500"} {
+ factory, stdout, _ := newAppsExecuteFactory(t)
+ err := runAppsShortcut(t, AppsFileList,
+ []string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--as", "user"}, factory, stdout)
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("page-size=%s: err = %T %v, want *errs.ValidationError", ps, err, err)
+ }
+ if ve.Param != "--page-size" {
+ t.Fatalf("page-size=%s: Param = %q, want --page-size", ps, ve.Param)
+ }
+ }
+}
+
+// TestAppsFileList_PageSizeBoundaryOK 验证边界值 1 与 200 通过校验(dry-run 不报错并把 page_size 下发)。
+func TestAppsFileList_PageSizeBoundaryOK(t *testing.T) {
+ for _, ps := range []string{"1", "200"} {
+ factory, stdout, _ := newAppsExecuteFactory(t)
+ if err := runAppsShortcut(t, AppsFileList,
+ []string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--dry-run", "--as", "user"},
+ factory, stdout); err != nil {
+ t.Fatalf("page-size=%s: dry-run err=%v", ps, err)
+ }
+ }
+}
+
// 过滤器 + 分页全部进 query(size-gt/lt 走 int,uploaded_since/until 原样)。
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
diff --git a/shortcuts/apps/apps_file_upload.go b/shortcuts/apps/apps_file_upload.go
index 6118a0006..0fef5bab5 100644
--- a/shortcuts/apps/apps_file_upload.go
+++ b/shortcuts/apps/apps_file_upload.go
@@ -14,7 +14,6 @@ import (
"strings"
"github.com/larksuite/cli/errs"
- "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,21 +46,7 @@ var AppsFileUpload = common.Shortcut{
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
- f := strings.TrimSpace(rctx.Str("file"))
- if f == "" {
- return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
- }
- st, err := rctx.FileIO().Stat(f)
- if err != nil {
- return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
- }
- if st.IsDir() {
- return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
- }
- if st.Size() > fileUploadMaxBytes {
- return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
- }
- return nil
+ return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
@@ -76,9 +61,9 @@ var AppsFileUpload = common.Shortcut{
return err
}
localPath := strings.TrimSpace(rctx.Str("file"))
- content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
+ content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
if err != nil {
- return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
+ return err
}
fileName := filepath.Base(localPath)
contentType := mimeByExt(fileName)
diff --git a/shortcuts/apps/apps_file_upload_test.go b/shortcuts/apps/apps_file_upload_test.go
index c82d8bcae..c0082a8c8 100644
--- a/shortcuts/apps/apps_file_upload_test.go
+++ b/shortcuts/apps/apps_file_upload_test.go
@@ -12,6 +12,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "runtime"
"strings"
"testing"
@@ -58,22 +59,17 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
}
}
-// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_upload,body.file_name 取文件 basename。
+// TestAppsFileUpload_DryRunPreUpload verifies that dry-run validates the local
+// file and previews the pre-upload request without reading or uploading it.
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
- // Validate 会 Stat --file(在 DryRun 之前),故 dry-run 也需要真实存在的文件。
- dir := t.TempDir()
- if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
+ absolutePath := filepath.Join(t.TempDir(), "logo.png")
+ if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
t.Fatal(err)
}
- oldWD, _ := os.Getwd()
- if err := os.Chdir(dir); err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileUpload,
- []string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
+ []string{"+file-upload", "--app-id", "app_x", "--file", absolutePath, "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env dryRunAPIEnvelope
@@ -87,6 +83,18 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
}
}
+func TestAppsFileUpload_DryRunRejectsMissingFile(t *testing.T) {
+ missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "logo.png")
+
+ factory, stdout, _ := newAppsExecuteFactory(t)
+ err := runAppsShortcut(t, AppsFileUpload,
+ []string{"+file-upload", "--app-id", "app_x", "--file", missingAbsolutePath, "--dry-run", "--as", "user"}, factory, stdout)
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
+ t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
+ }
+}
+
// 三步直传:pre-upload → 客户端 PUT 字节 → callback。
func TestAppsFileUpload_EndToEnd(t *testing.T) {
var putBody []byte
@@ -149,6 +157,142 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
}
}
+// TestAppsFileUpload_AcceptsAbsolutePath verifies that file-upload can read an
+// absolute path outside the current working directory.
+func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
+ var putBody []byte
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ putBody, _ = io.ReadAll(r.Body)
+ w.Header().Set("ETag", `"etag-abs"`)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ // Keep the process cwd unchanged so the temporary file is outside it.
+ dir := t.TempDir()
+ absFile := filepath.Join(dir, "report.pdf")
+ if !filepath.IsAbs(absFile) {
+ t.Fatalf("test setup: %q is not absolute", absFile)
+ }
+ if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ reg.Register(&httpmock.Stub{
+ Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
+ "file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
+ }},
+ })
+
+ if err := runAppsShortcut(t, AppsFileUpload,
+ []string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
+ t.Fatalf("execute with absolute path err=%v", err)
+ }
+ if string(putBody) != "PDFBYTES" {
+ t.Fatalf("PUT body = %q, want file bytes", putBody)
+ }
+}
+
+func TestAppsFileUpload_AcceptsParentRelativePathOutsideCWD(t *testing.T) {
+ var putBody []byte
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ putBody, _ = io.ReadAll(r.Body)
+ w.Header().Set("ETag", `"etag-parent"`)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ root := t.TempDir()
+ workDir := filepath.Join(root, "work")
+ if err := os.Mkdir(workDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("PARENT"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ oldWD, _ := os.Getwd()
+ if err := os.Chdir(workDir); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
+
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ reg.Register(&httpmock.Stub{
+ Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-parent"}},
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
+ "file_name": "report.pdf", "path": "/parent.pdf", "size_bytes": 6,
+ }},
+ })
+
+ if err := runAppsShortcut(t, AppsFileUpload,
+ []string{"+file-upload", "--app-id", "app_x", "--file", filepath.Join("..", "report.pdf"), "--as", "user"}, factory, stdout); err != nil {
+ t.Fatalf("execute with parent-relative path err=%v", err)
+ }
+ if string(putBody) != "PARENT" {
+ t.Fatalf("PUT body = %q, want PARENT", putBody)
+ }
+}
+
+func TestAppsFileUpload_RejectsFileAboveLimit(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "too-large.bin")
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := f.Truncate(fileUploadMaxBytes + 1); err != nil {
+ _ = f.Close()
+ t.Fatal(err)
+ }
+ if err := f.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ factory, stdout, _ := newAppsExecuteFactory(t)
+ err = runAppsShortcut(t, AppsFileUpload,
+ []string{"+file-upload", "--app-id", "app_x", "--file", path, "--as", "user"}, factory, stdout)
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
+ t.Fatalf("error = %T %v, want --file ValidationError", err, err)
+ }
+ if !strings.Contains(validationErr.Error(), "limit") {
+ t.Fatalf("error = %v, want size limit context", validationErr)
+ }
+}
+
+func TestAppsFileUpload_RejectsDeviceWithoutReadingIt(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("/dev/zero is unavailable on Windows")
+ }
+ if _, err := os.Stat("/dev/zero"); err != nil {
+ t.Skipf("/dev/zero unavailable: %v", err)
+ }
+
+ factory, stdout, _ := newAppsExecuteFactory(t)
+ err := runAppsShortcut(t, AppsFileUpload,
+ []string{"+file-upload", "--app-id", "app_x", "--file", "/dev/zero", "--as", "user"}, factory, stdout)
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
+ t.Fatalf("error = %T %v, want --file ValidationError", err, err)
+ }
+ if !strings.Contains(validationErr.Error(), "regular file") {
+ t.Fatalf("error = %v, want non-regular-file context", validationErr)
+ }
+}
+
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName:空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
func TestSanitizeUploadFileName_Cases(t *testing.T) {
cases := []struct{ in, want string }{
diff --git a/shortcuts/apps/apps_get.go b/shortcuts/apps/apps_get.go
index aaac17030..a1671ea1a 100644
--- a/shortcuts/apps/apps_get.go
+++ b/shortcuts/apps/apps_get.go
@@ -17,10 +17,11 @@ import (
var AppsGet = common.Shortcut{
Service: appsService,
Command: "+get",
- Description: "Get a single app's detail by app ID (returns app_type, name, description, publish status, etc.)",
+ Description: "Get a single app's detail by app ID or meta token (returns app_type, name, description, publish status, etc.)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +get --app-id ",
+ "Example: lark-cli apps +get --app-id ",
"Example: lark-cli apps +get --app-id --dry-run",
"Tip: extract app type with --jq '.data.app.app_type'",
},
@@ -28,7 +29,7 @@ var AppsGet = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
- {Name: "app-id", Desc: "app ID", Required: true},
+ {Name: "app-id", Desc: "app ID or meta token", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
@@ -40,7 +41,7 @@ var AppsGet = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
- Desc("Get app detail (returns app_id, app_type, name, description, icon_url, created_at, updated_at, is_published)")
+ Desc("Get app detail (returns app_id, meta_token, app_type, name, description, icon_url, created_at, updated_at, is_published)")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
@@ -54,6 +55,9 @@ var AppsGet = common.Shortcut{
return
}
fmt.Fprintf(w, "app_id: %v\n", app["app_id"])
+ if mt, ok := app["meta_token"].(string); ok && mt != "" {
+ fmt.Fprintf(w, "meta_token: %s\n", mt)
+ }
fmt.Fprintf(w, "app_type: %v\n", app["app_type"])
fmt.Fprintf(w, "name: %v\n", app["name"])
if desc, ok := app["description"].(string); ok && desc != "" {
diff --git a/shortcuts/apps/apps_html_publish.go b/shortcuts/apps/apps_html_publish.go
index 0775d848f..a4a149863 100644
--- a/shortcuts/apps/apps_html_publish.go
+++ b/shortcuts/apps/apps_html_publish.go
@@ -14,7 +14,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
- "github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -38,9 +37,13 @@ var AppsHTMLPublish = common.Shortcut{
{Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / .aws/credentials / etc. in the publish payload)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
- if strings.TrimSpace(rctx.Str("app-id")) == "" {
+ appID := strings.TrimSpace(rctx.Str("app-id"))
+ if appID == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
+ if err := validateRealAppID(appID); err != nil {
+ return err
+ }
path := strings.TrimSpace(rctx.Str("path"))
if path == "" {
return appsValidationParamError("--path", "--path is required")
@@ -73,9 +76,11 @@ var AppsHTMLPublish = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
path := strings.TrimSpace(rctx.Str("path"))
dry := common.NewDryRunAPI()
- dry.Desc("Pack tar.gz and publish HTML app (actual API path determined at runtime by app type; returns url or release_id)")
- dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
- Set("content_type", "multipart/form-data")
+ dry.Desc("Pack tar.gz → GET pre_release for TOS upload URL → PUT tar.gz to TOS → POST release-create with tos_path; returns release_id")
+ dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID))).
+ PUT(" (from pre_release response)").
+ POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))).
+ Body(map[string]string{"tos_path": ""})
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path)
if err != nil {
@@ -123,16 +128,7 @@ var AppsHTMLPublish = common.Shortcut{
Path: strings.TrimSpace(rctx.Str("path")),
}
- appType := queryAppType(ctx, rctx, spec.AppID)
-
- var out map[string]interface{}
- var err error
- if appType == "modern_html" {
- out, err = runHTMLPublishTOS(ctx, rctx, spec)
- } else {
- client := appsHTMLPublishAPI{runtime: rctx}
- out, err = runHTMLPublish(ctx, rctx.FileIO(), client, spec)
- }
+ out, err := runHTMLPublishTOS(ctx, rctx, spec)
if err != nil {
return err
}
@@ -264,25 +260,7 @@ func prepareHTMLPublishTarball(fio fileio.FileIO, path string) (*htmlPublishTarb
return tarball, nil
}
-func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
- tarball, err := prepareHTMLPublishTarball(fio, spec.Path)
- if err != nil {
- return nil, err
- }
-
- resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
- if err != nil {
- return nil, client.WrapDoAPIError(err)
- }
-
- out := map[string]interface{}{}
- if resp.URL != "" {
- out["url"] = resp.URL
- }
- return out, nil
-}
-
-// runHTMLPublishTOS handles the modern_html publish path: validate → tar.gz →
+// runHTMLPublishTOS handles the publish path: validate → tar.gz →
// call pre_release to get TOS upload URL → upload tar.gz to TOS → return
// tos_path for +release-create --tos-path.
func runHTMLPublishTOS(ctx context.Context, rctx *common.RuntimeContext, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
diff --git a/shortcuts/apps/apps_html_publish_test.go b/shortcuts/apps/apps_html_publish_test.go
index c8a2796a0..a9526442e 100644
--- a/shortcuts/apps/apps_html_publish_test.go
+++ b/shortcuts/apps/apps_html_publish_test.go
@@ -5,7 +5,6 @@ package apps
import (
"context"
- "errors"
"net/http"
"net/http/httptest"
"os"
@@ -23,20 +22,6 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
-type fakeAppsHTMLPublishClient struct {
- resp *htmlPublishResponse
- err error
- calls []string
-}
-
-func (f *fakeAppsHTMLPublishClient) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
- f.calls = append(f.calls, appID)
- if f.err != nil {
- return nil, f.err
- }
- return f.resp, nil
-}
-
func writeAppsSampleSite(t *testing.T) string {
t.Helper()
dir := t.TempDir()
@@ -46,71 +31,19 @@ func writeAppsSampleSite(t *testing.T) string {
return dir
}
-func TestRunHTMLPublish_HappyPath(t *testing.T) {
- site := writeAppsSampleSite(t)
- fake := &fakeAppsHTMLPublishClient{
- resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
- }
- out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
- if err != nil {
- t.Fatalf("err=%v", err)
- }
- if out["url"] != "https://miaoda/app_x" {
- t.Fatalf("url=%v", out["url"])
- }
- if len(fake.calls) != 1 || fake.calls[0] != "app_x" {
- t.Fatalf("calls=%v", fake.calls)
- }
-}
-
-func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
- // Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步" (legacy html path only):
- // envelope 只含 url,未来若有人加 status / release_id 字段会被这个测试拦截。
- site := writeAppsSampleSite(t)
- fake := &fakeAppsHTMLPublishClient{
- resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
- }
- out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
- if err != nil {
- t.Fatalf("err=%v", err)
- }
- if len(out) != 1 {
- t.Fatalf("envelope should only contain 'url', got %d keys: %v", len(out), out)
- }
- if _, ok := out["url"]; !ok {
- t.Fatalf("envelope missing 'url': %v", out)
- }
-}
-
-func TestRunHTMLPublish_ClientErrorPropagated(t *testing.T) {
- site := writeAppsSampleSite(t)
- wantErr := errors.New("server timeout")
- fake := &fakeAppsHTMLPublishClient{err: wantErr}
- _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
- if !errors.Is(err, wantErr) {
- t.Fatalf("err=%v", err)
- }
-}
-
-func TestRunHTMLPublish_PathNotFound(t *testing.T) {
- fake := &fakeAppsHTMLPublishClient{}
- _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: "/nonexistent"})
+func TestPrepareHTMLPublishTarball_PathNotFound(t *testing.T) {
+ _, err := prepareHTMLPublishTarball(newTestFIO(), "/nonexistent")
if err == nil {
t.Fatalf("expected error")
}
- if len(fake.calls) != 0 {
- t.Fatalf("client should not be called when path invalid")
- }
}
-func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
- // 目录形态:缺 index.html 应该被拦
+func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte(""), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
- fake := &fakeAppsHTMLPublishClient{}
- _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
+ _, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err == nil {
t.Fatalf("expected error for missing index.html")
}
@@ -121,13 +54,9 @@ func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
- if len(fake.calls) != 0 {
- t.Fatalf("client should not be called when index.html missing")
- }
}
-func TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) {
- // 目录含 index.html 应该正常走完
+func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
@@ -135,57 +64,49 @@ func TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "extra.html"), []byte(""), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
- fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
- if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
+ tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
+ if err != nil {
t.Fatalf("err=%v", err)
}
- if len(fake.calls) != 1 {
- t.Fatalf("client should be called when index.html present")
+ if tarball == nil || tarball.Size == 0 {
+ t.Fatalf("expected non-empty tarball")
}
}
-func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
- // 单文件形态:文件名不是 index.html 也要拦
+func TestPrepareHTMLPublishTarball_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
dir := t.TempDir()
single := filepath.Join(dir, "foo.html")
if err := os.WriteFile(single, []byte(""), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
- fake := &fakeAppsHTMLPublishClient{}
- _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single})
+ _, err := prepareHTMLPublishTarball(newTestFIO(), single)
if err == nil {
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
}
requireAppsValidationProblem(t, err)
- if len(fake.calls) != 0 {
- t.Fatalf("client must not be called when index.html missing")
- }
}
-func TestRunHTMLPublish_SingleFileNamedIndexPasses(t *testing.T) {
- // 单文件形态:文件名恰好就是 index.html → 放行
+func TestPrepareHTMLPublishTarball_SingleFileNamedIndexPasses(t *testing.T) {
dir := t.TempDir()
single := filepath.Join(dir, "index.html")
if err := os.WriteFile(single, []byte(""), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
- fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
- if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single}); err != nil {
+ tarball, err := prepareHTMLPublishTarball(newTestFIO(), single)
+ if err != nil {
t.Fatalf("err=%v", err)
}
- if len(fake.calls) != 1 {
- t.Fatalf("client should be called for single index.html")
+ if tarball == nil || tarball.Size == 0 {
+ t.Fatalf("expected non-empty tarball")
}
}
-func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
- // 把上限调到 100 字节验证拦截,defer 恢复原值避免污染其它测试。
+func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
orig := maxHTMLPublishTarballBytes
maxHTMLPublishTarballBytes = 100
defer func() { maxHTMLPublishTarballBytes = orig }()
dir := t.TempDir()
- // 写 index.html(满足新加的 index 校验)+ 大文件超 100 字节上限。
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
@@ -194,8 +115,7 @@ func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
t.Fatalf("write: %v", err)
}
- fake := &fakeAppsHTMLPublishClient{}
- _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
+ _, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err == nil {
t.Fatalf("expected oversize error")
}
@@ -206,9 +126,6 @@ func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
- if len(fake.calls) != 0 {
- t.Fatalf("client should not be called when tarball oversize")
- }
}
func TestMaxHTMLPublishTarballBytes_Default(t *testing.T) {
@@ -264,8 +181,17 @@ func TestAppsHTMLPublish_DryRunPrintsManifest(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
- if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code") {
- t.Fatalf("dry-run missing endpoint: %s", got)
+ if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/pre_release") {
+ t.Fatalf("dry-run missing pre_release endpoint: %s", got)
+ }
+ if !strings.Contains(got, "presigned_upload_url") {
+ t.Fatalf("dry-run missing TOS PUT step: %s", got)
+ }
+ if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/releases") {
+ t.Fatalf("dry-run missing release-create endpoint: %s", got)
+ }
+ if !strings.Contains(got, "tos_path") {
+ t.Fatalf("dry-run missing tos_path in release-create body: %s", got)
}
if !strings.Contains(got, "index.html") {
t.Fatalf("dry-run missing file list: %s", got)
@@ -500,9 +426,7 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
t.Fatalf("write: %v", err)
}
- fake := &fakeAppsHTMLPublishClient{}
- _, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
- appsHTMLPublishSpec{AppID: "app_x", Path: dir})
+ _, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err == nil {
t.Fatalf("expected raw-size cap to fire")
}
@@ -510,9 +434,6 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") {
t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message)
}
- if len(fake.calls) != 0 {
- t.Fatalf("client must not be called when raw cap hit")
- }
}
func TestOversizeHTMLFiles(t *testing.T) {
@@ -555,8 +476,7 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
- fake := &fakeAppsHTMLPublishClient{}
- _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
+ _, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err == nil {
t.Fatalf("expected per-file oversize error")
}
@@ -567,13 +487,9 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
- if len(fake.calls) != 0 {
- t.Fatalf("client must not be called when an HTML file is oversize")
- }
}
-func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
- // 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
+func TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
@@ -585,12 +501,12 @@ func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
- fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
- if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
+ tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
+ if err != nil {
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
}
- if len(fake.calls) != 1 {
- t.Fatalf("client should be called; calls=%v", fake.calls)
+ if tarball == nil || tarball.Size == 0 {
+ t.Fatalf("expected non-empty tarball")
}
}
diff --git a/shortcuts/apps/apps_init.go b/shortcuts/apps/apps_init.go
index 616ddf0f3..f159756d0 100644
--- a/shortcuts/apps/apps_init.go
+++ b/shortcuts/apps/apps_init.go
@@ -74,15 +74,18 @@ type appTypePolicy struct {
// skipSkillsSync skips the conditional `npx ... skills sync --local` step on
// the non-empty (`app sync`) scaffold path.
skipSkillsSync bool
+ // skipAppSync skips `npx ... app sync` on the non-empty repo path.
+ skipAppSync bool
}
// appTypePolicies maps an app_type to its +init control strategy. Types absent
// from the map get the zero-value policy (install runs, env is pulled, skills
// are synced).
var appTypePolicies = map[string]appTypePolicy{
- // modern_html is a static HTML site: no dependencies to install, no startup
- // env vars to pull, and no steering skills to sync.
- "modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true},
+ // modern_html / html are static HTML sites: no dependencies to install,
+ // no startup env vars to pull, no steering skills to sync, and no app sync.
+ "modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
+ "html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
}
// policyForAppType returns the +init control strategy for appType. Unlisted
@@ -122,9 +125,13 @@ var AppsInit = common.Shortcut{
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
- if strings.TrimSpace(rctx.Str("app-id")) == "" {
+ appID := strings.TrimSpace(rctx.Str("app-id"))
+ if appID == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
+ if err := validateRealAppID(appID); err != nil {
+ return err
+ }
if sp := strings.TrimSpace(rctx.Str("source-path")); sp != "" {
if err := charcheck.RejectControlChars(sp, "--source-path"); err != nil {
return appsValidationParamError("--source-path", "%v", err).WithCause(err)
@@ -334,11 +341,19 @@ func ensureMetaAppID(dir, appID string) error {
// each is not already resolvable from local/global/system config, so a
// developer's existing identity is never overwritten. Each key is handled
// independently (a machine with only user.name set still gets a default email).
-func ensureGitIdentity(ctx context.Context, dir string) error {
- if err := ensureGitConfigValue(ctx, dir, "user.name", defaultGitUserName); err != nil {
+func ensureGitIdentity(ctx context.Context, dir, authorName, authorEmail string) error {
+ name := strings.TrimSpace(authorName)
+ if name == "" {
+ name = defaultGitUserName
+ }
+ email := strings.TrimSpace(authorEmail)
+ if email == "" {
+ email = defaultGitUserEmail
+ }
+ if err := ensureGitConfigValue(ctx, dir, "user.name", name); err != nil {
return err
}
- return ensureGitConfigValue(ctx, dir, "user.email", defaultGitUserEmail)
+ return ensureGitConfigValue(ctx, dir, "user.email", email)
}
// ensureGitConfigValue sets =fallback in the repo-local git config when key
@@ -400,13 +415,16 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s
}
return scaffoldKindInit, nil
}
- if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
- return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
+ policy := policyForAppType(appType)
+ if !policy.skipAppSync {
+ if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
+ return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
+ }
}
if err := ensureMetaAppID(dir, appID); err != nil {
return "", err
}
- if !policyForAppType(appType).skipSkillsSync && !hasSteeringSkills(dir) {
+ if !policy.skipSkillsSync && !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
}
@@ -436,26 +454,38 @@ func scaffoldInitArgs(appType, appID, sourcePath string) []string {
return base
}
-// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
-// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
-// matches the contract emitted by `apps +git-credential-init`.
-func parseRepoURLFromEnvelope(stdout string) (string, error) {
+// credentialInitResult holds the fields parsed from +git-credential-init output.
+type credentialInitResult struct {
+ RepositoryURL string
+ CommitAuthorName string
+ CommitAuthorEmail string
+}
+
+// parseCredentialInitEnvelope extracts fields from a +git-credential-init JSON
+// envelope ({"ok":true,"data":{"repository_url":"...","commit_author_name":"...","commit_author_email":"..."}}).
+func parseCredentialInitEnvelope(stdout string) (credentialInitResult, error) {
var env struct {
OK bool `json:"ok"`
Data struct {
- RepositoryURL string `json:"repository_url"`
+ RepositoryURL string `json:"repository_url"`
+ CommitAuthorName string `json:"commit_author_name"`
+ CommitAuthorEmail string `json:"commit_author_email"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
- return "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
+ return credentialInitResult{}, appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
}
if !env.OK {
- return "", appsSubprocessEnvelopeError("+git-credential-init reported failure")
+ return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init reported failure")
}
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
- return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
+ return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
}
- return env.Data.RepositoryURL, nil
+ return credentialInitResult{
+ RepositoryURL: env.Data.RepositoryURL,
+ CommitAuthorName: env.Data.CommitAuthorName,
+ CommitAuthorEmail: env.Data.CommitAuthorEmail,
+ }, nil
}
// parseEnvFileFromEnvelope extracts data.env_file from a `+env-pull` success
@@ -527,7 +557,10 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return err
}
- appType := queryAppType(ctx, rctx, appID)
+ appType, err := queryAppType(ctx, rctx, appID)
+ if err != nil {
+ return err
+ }
policy := policyForAppType(appType)
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
@@ -595,16 +628,16 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
}
initLogf(rctx, "Issuing repository credentials for %s...", appID)
- repoURL, err := issueCredentials(ctx, rctx, appID)
+ cred, err := issueCredentials(ctx, rctx, appID)
if err != nil {
return err
}
- if err := validateRepoURLScheme(repoURL); err != nil {
+ if err := validateRepoURLScheme(cred.RepositoryURL); err != nil {
return err
}
initLogf(rctx, "Cloning into %s...", dir)
- if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
+ if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", cred.RepositoryURL, dir); err != nil {
return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err))
}
initLogf(rctx, "Checking out %s...", defaultInitBranch)
@@ -612,9 +645,10 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
}
- // Ensure a committer identity exists before the scaffold commit; only sets
- // repo-local defaults when none is configured (existing identity is kept).
- if err := ensureGitIdentity(ctx, dir); err != nil {
+ // Ensure a committer identity exists before the scaffold commit. Uses the
+ // author name/email from +git-credential-init when available; falls back
+ // to lark-cli-bot defaults when the server does not provide them.
+ if err := ensureGitIdentity(ctx, dir, cred.CommitAuthorName, cred.CommitAuthorEmail); err != nil {
return err
}
@@ -643,7 +677,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
out := map[string]interface{}{
"app_id": appID,
- "repository_url": redactURLCredentials(repoURL),
+ "repository_url": redactURLCredentials(cred.RepositoryURL),
"branch": defaultInitBranch,
"clone_path": dir,
"scaffold": scaffold,
@@ -721,10 +755,10 @@ func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string
// issueCredentials runs ` apps +git-credential-init --app-id --format json`
// and returns the repo_url it reports. Forwards --as when set.
-func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
+func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (credentialInitResult, error) {
self, err := os.Executable()
if err != nil {
- return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
+ return credentialInitResult{}, errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
}
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
@@ -732,11 +766,11 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
}
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
if err != nil {
- return "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
+ return credentialInitResult{}, appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
WithHint("ensure apps +git-credential-init is available and you are logged in").
WithCause(err)
}
- return parseRepoURLFromEnvelope(stdout)
+ return parseCredentialInitEnvelope(stdout)
}
// commitAndPushIfDirty commits and pushes only when the working tree has
diff --git a/shortcuts/apps/apps_init_test.go b/shortcuts/apps/apps_init_test.go
index 26684b193..04f0dbb33 100644
--- a/shortcuts/apps/apps_init_test.go
+++ b/shortcuts/apps/apps_init_test.go
@@ -21,6 +21,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/internal/testutil/gitcmd"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -110,18 +111,24 @@ func TestDefaultCloneDir(t *testing.T) {
// --- pure-function tests ---
func TestParseRepoURL(t *testing.T) {
- url, err := parseRepoURLFromEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git"}}`)
+ result, err := parseCredentialInitEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git","commit_author_name":"Alice","commit_author_email":"alice@example.com"}}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- if url != "http://u:t@h/app_x.git" {
- t.Errorf("got %q", url)
+ if result.RepositoryURL != "http://u:t@h/app_x.git" {
+ t.Errorf("RepositoryURL got %q", result.RepositoryURL)
+ }
+ if result.CommitAuthorName != "Alice" {
+ t.Errorf("CommitAuthorName got %q", result.CommitAuthorName)
+ }
+ if result.CommitAuthorEmail != "alice@example.com" {
+ t.Errorf("CommitAuthorEmail got %q", result.CommitAuthorEmail)
}
}
func TestParseRepoURL_Errors(t *testing.T) {
for _, in := range []string{`not json`, `{"ok":false,"data":{}}`, `{"ok":true,"data":{}}`, `{"ok":true,"data":{"repository_url":""}}`} {
- if _, err := parseRepoURLFromEnvelope(in); err == nil {
+ if _, err := parseCredentialInitEnvelope(in); err == nil {
t.Errorf("expected error for %q", in)
}
}
@@ -149,6 +156,22 @@ func withFakeRunner(t *testing.T, f *fakeCommandRunner) {
t.Cleanup(func() { initRunner = orig })
}
+func stubAppType(reg *httpmock.Registry, appID, appType string) {
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/spark/v1/apps/" + appID,
+ Body: map[string]interface{}{
+ "code": float64(0),
+ "data": map[string]interface{}{
+ "app": map[string]interface{}{
+ "app_id": appID,
+ "app_type": appType,
+ },
+ },
+ },
+ })
+}
+
func credInitOK(repoURL string) fakeCallResult {
return fakeCallResult{stdout: `{"ok":true,"data":{"repository_url":"` + repoURL + `"}}`}
}
@@ -313,7 +336,8 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
"git status": {stdout: " M src/app.ts\n"}, // scaffold produced changes
}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -354,7 +378,8 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
}
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
}
@@ -423,7 +448,8 @@ func TestAppsInit_HappyPathCleanTree(t *testing.T) {
"git status": {}, // clean tree after scaffold -> no commit/push
}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
@@ -472,7 +498,8 @@ func TestAppsInit_DirtyTreeCommitPush(t *testing.T) {
"git status": {stdout: " M file.txt"},
}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
@@ -542,7 +569,8 @@ func TestAppsInit_CloneFailure(t *testing.T) {
"git clone": {stderr: "fatal: unable to access 'http://u:t@h/r.git'", err: errors.New("exit 128")},
}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
@@ -616,7 +644,8 @@ func TestAppsInit_AsPassthrough(t *testing.T) {
"git status": {},
}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
// AppsInit.AuthTypes is ["user"], so the framework rejects --as bot. Use
@@ -722,7 +751,7 @@ func TestIsEmptyRepo(t *testing.T) {
// newAppsExecuteFactoryWithStderr mirrors newAppsExecuteFactory but also returns
// the stderr buffer, so tests can assert on the +init progress log lines that
// initLogf writes to IO().ErrOut.
-func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
+func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -732,12 +761,12 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
- factory, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
- return factory, stdout, stderr
+ factory, stdout, stderr, reg := cmdutil.TestFactory(t, cfg)
+ return factory, stdout, stderr, reg
}
func TestAppsInit_Req1_Wording(t *testing.T) {
- factory, stdout, _ := newAppsExecuteFactoryWithStderr(t)
+ factory, stdout, _, _ := newAppsExecuteFactoryWithStderr(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
@@ -766,7 +795,8 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
"git status": {},
}}
withFakeRunner(t, f)
- factory2, stdout2, stderr2 := newAppsExecuteFactoryWithStderr(t)
+ factory2, stdout2, stderr2, reg2 := newAppsExecuteFactoryWithStderr(t)
+ stubAppType(reg2, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory2, stdout2); err != nil {
t.Fatalf("run err=%v", err)
@@ -829,7 +859,8 @@ func TestAppsInit_EmptyRepo_TwoCommits(t *testing.T) {
"git status": {stdout: " A src/app.ts\n A .spark/meta.json\n A .agent/skills/steering/x.md\n"},
}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -870,7 +901,8 @@ func TestAppsInit_EmptyRepo_AppCodeOnly_SingleCommit(t *testing.T) {
"git status": {stdout: " A src/app.ts\n"},
}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -890,7 +922,8 @@ func TestAppsInit_EmptyRepo_ConfigOnly_SingleCommit(t *testing.T) {
"git status": {stdout: " A .spark/meta.json\n"},
}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -910,7 +943,8 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
"git status": {stdout: " M file.txt\n M .spark/meta.json\n"},
}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -929,8 +963,7 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
// gitMust runs a git command in dir with a real binary, failing the test on error.
func gitMust(t *testing.T, dir string, args ...string) string {
t.Helper()
- cmd := exec.Command("git", args...)
- cmd.Dir = dir
+ cmd := gitcmd.Command(dir, args...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v in %s failed: %v\n%s", args, dir, err, out)
@@ -946,6 +979,7 @@ func TestCommitAndPushIfDirty_RealGit_IgnoredAgentDir(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
+ gitcmd.SetSynchronousMaintenanceEnv(t)
// Bare remote so `git push origin sprint/default` succeeds.
remote := t.TempDir()
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
@@ -1067,6 +1101,7 @@ func TestCommitAndPushIfDirty_RealGit_NonEmptyUpgrade(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
+ gitcmd.SetSynchronousMaintenanceEnv(t)
remote := t.TempDir()
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
@@ -1289,7 +1324,8 @@ func TestAppsInit_EnvPull_Success(t *testing.T) {
"env-pull": envPullOK("/abs/app_x/.env.local"),
}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -1327,7 +1363,8 @@ func TestAppsInit_EnvPull_NonFatal(t *testing.T) {
},
}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
@@ -1366,7 +1403,8 @@ func TestAppsInit_AlreadyInitialized_RunsEnvPull(t *testing.T) {
envFile := filepath.Join(abs, ".env.local")
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -1413,7 +1451,8 @@ func TestAppsInit_AlreadyInitialized_EnvPullFailure_NonFatal(t *testing.T) {
},
}}
withFakeRunner(t, f)
- factory, stdout, _ := newAppsExecuteFactory(t)
+ factory, stdout, reg := newAppsExecuteFactory(t)
+ stubAppType(reg, "app_x", "FULL_STACK")
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
}
@@ -1705,13 +1744,15 @@ func TestScaffoldInitArgs_WithAppType(t *testing.T) {
}
func TestPolicyForAppType(t *testing.T) {
- // modern_html decouples all control points: skip install, env-pull, skills sync.
- if p := policyForAppType("modern_html"); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync {
- t.Errorf("modern_html policy = %+v, want all skip flags set", p)
+ // modern_html and html decouple all control points: skip install, env-pull, skills sync, app sync.
+ for _, at := range []string{"modern_html", "html"} {
+ if p := policyForAppType(at); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync || !p.skipAppSync {
+ t.Errorf("%s policy = %+v, want all skip flags set", at, p)
+ }
}
// Unlisted types (including "") get the zero-value policy: everything runs.
for _, at := range []string{"full_stack", "", "backend"} {
- if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync {
+ if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync || p.skipAppSync {
t.Errorf("policy for %q = %+v, want zero value", at, p)
}
}
@@ -1757,7 +1798,7 @@ func configSetValue(calls [][]string, key string) (string, bool) {
func TestEnsureGitIdentity_SetsDefaultsWhenUnset(t *testing.T) {
f := &fakeCommandRunner{} // no "git config" result → `--get` returns empty stdout
withFakeRunner(t, f)
- if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
+ if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v, ok := configSetValue(f.calls, "user.name"); !ok || v != defaultGitUserName {
@@ -1774,7 +1815,7 @@ func TestEnsureGitIdentity_RespectsExisting(t *testing.T) {
"git config": {stdout: "Existing Dev\n"},
}}
withFakeRunner(t, f)
- if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
+ if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := configSetValue(f.calls, "user.name"); ok {
@@ -1790,7 +1831,7 @@ func TestEnsureGitIdentity_SetFailurePropagates(t *testing.T) {
"git config": {stderr: "boom", err: errors.New("exit 1")},
}}
withFakeRunner(t, f)
- if err := ensureGitIdentity(context.Background(), "/repo"); err == nil {
+ if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err == nil {
t.Error("expected error when git config set fails")
}
}
diff --git a/shortcuts/apps/apps_meta.go b/shortcuts/apps/apps_meta.go
index e7d7322f7..efdcafa2b 100644
--- a/shortcuts/apps/apps_meta.go
+++ b/shortcuts/apps/apps_meta.go
@@ -13,22 +13,25 @@ import (
)
// queryAppType fetches the app's type string from the server via
-// GET /open-apis/spark/v1/apps/{appID}. The server returns uppercase
-// values ("HTML", "FULL_STACK", "MODERN_HTML"); this function normalizes
-// to lowercase. Returns "" when the API is unavailable or returns an
-// error — callers fall back to legacy behavior.
-func queryAppType(ctx context.Context, rctx *common.RuntimeContext, appID string) string {
- path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
+// GET /open-apis/spark/v1/apps/{identifier}. The identifier can be either
+// an app_id or a meta_token — the server resolves both. The server returns
+// uppercase app_type values ("HTML", "FULL_STACK", "MODERN_HTML");
+// this function normalizes to lowercase. Returns an error when the API
+// is unavailable or the response is malformed — callers must not proceed
+// with a fallback type to avoid creating the wrong project scaffold.
+func queryAppType(ctx context.Context, rctx *common.RuntimeContext, identifier string) (string, error) {
+ path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(identifier))
data, err := rctx.CallAPITyped("GET", path, nil, nil)
if err != nil {
- fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: %v\n", err)
- return ""
+ return "", err
}
appRaw, _ := data["app"].(map[string]interface{})
if appRaw == nil {
- fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: response missing app object\n")
- return ""
+ return "", appsSubprocessEnvelopeError("query app type: response missing app object")
}
appType, _ := appRaw["app_type"].(string)
- return strings.ToLower(appType)
+ if strings.TrimSpace(appType) == "" {
+ return "", appsSubprocessEnvelopeError("query app type: response missing app_type")
+ }
+ return strings.ToLower(appType), nil
}
diff --git a/shortcuts/apps/apps_meta_test.go b/shortcuts/apps/apps_meta_test.go
index bc41eb533..3d410c8dd 100644
--- a/shortcuts/apps/apps_meta_test.go
+++ b/shortcuts/apps/apps_meta_test.go
@@ -43,7 +43,10 @@ func TestQueryAppType_Success(t *testing.T) {
},
})
- result := queryAppType(context.Background(), rt, "app_test")
+ result, err := queryAppType(context.Background(), rt, "app_test")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
if result != "modern_html" {
t.Errorf("queryAppType = %q, want modern_html", result)
}
@@ -65,7 +68,10 @@ func TestQueryAppType_FullStack(t *testing.T) {
},
})
- result := queryAppType(context.Background(), rt, "app_fs")
+ result, err := queryAppType(context.Background(), rt, "app_fs")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
if result != "full_stack" {
t.Errorf("queryAppType = %q, want full_stack", result)
}
@@ -87,7 +93,10 @@ func TestQueryAppType_Html(t *testing.T) {
},
})
- result := queryAppType(context.Background(), rt, "app_html")
+ result, err := queryAppType(context.Background(), rt, "app_html")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
if result != "html" {
t.Errorf("queryAppType = %q, want html", result)
}
@@ -102,9 +111,9 @@ func TestQueryAppType_APIError(t *testing.T) {
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
})
- result := queryAppType(context.Background(), rt, "app_bad")
- if result != "" {
- t.Errorf("queryAppType = %q, want empty on error", result)
+ _, err := queryAppType(context.Background(), rt, "app_bad")
+ if err == nil {
+ t.Error("expected error on API failure")
}
}
@@ -119,9 +128,9 @@ func TestQueryAppType_MissingAppObject(t *testing.T) {
},
})
- result := queryAppType(context.Background(), rt, "app_no")
- if result != "" {
- t.Errorf("queryAppType = %q, want empty when app object missing", result)
+ _, err := queryAppType(context.Background(), rt, "app_no")
+ if err == nil {
+ t.Error("expected error when app object missing")
}
}
@@ -141,8 +150,8 @@ func TestQueryAppType_EmptyAppType(t *testing.T) {
},
})
- result := queryAppType(context.Background(), rt, "app_empty")
- if result != "" {
- t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
+ _, err := queryAppType(context.Background(), rt, "app_empty")
+ if err == nil {
+ t.Error("expected error when app_type is empty")
}
}
diff --git a/shortcuts/apps/apps_release_create.go b/shortcuts/apps/apps_release_create.go
index 466b2f17c..a33a79207 100644
--- a/shortcuts/apps/apps_release_create.go
+++ b/shortcuts/apps/apps_release_create.go
@@ -31,9 +31,13 @@ var AppsReleaseCreate = common.Shortcut{
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
- if strings.TrimSpace(rctx.Str("app-id")) == "" {
+ appID := strings.TrimSpace(rctx.Str("app-id"))
+ if appID == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
+ if err := validateRealAppID(appID); err != nil {
+ return err
+ }
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
diff --git a/shortcuts/apps/apps_release_get.go b/shortcuts/apps/apps_release_get.go
index 133765f78..c0dfa79b5 100644
--- a/shortcuts/apps/apps_release_get.go
+++ b/shortcuts/apps/apps_release_get.go
@@ -30,9 +30,13 @@ var AppsReleaseGet = common.Shortcut{
{Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
- if strings.TrimSpace(rctx.Str("app-id")) == "" {
+ appID := strings.TrimSpace(rctx.Str("app-id"))
+ if appID == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
+ if err := validateRealAppID(appID); err != nil {
+ return err
+ }
if strings.TrimSpace(rctx.Str("release-id")) == "" {
return appsValidationParamError("--release-id", "--release-id is required")
}
diff --git a/shortcuts/apps/common.go b/shortcuts/apps/common.go
index 8a9627600..b76239021 100644
--- a/shortcuts/apps/common.go
+++ b/shortcuts/apps/common.go
@@ -41,6 +41,21 @@ func withAppsHint(err error, hint string) error {
return err
}
+// validateRealAppID checks that --app-id is a real app ID (app_ prefix).
+// meta_token values are rejected with a hint to resolve via +get first.
+func validateRealAppID(appID string) error {
+ if !strings.HasPrefix(appID, "app_") {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument,
+ `--app-id must be an app_id starting with "app_".`,
+ ).WithParam("--app-id").WithHint(
+ `If you have a meta_token or a /page// link, first resolve it:
+lark-cli apps +get --app-id -q '.data.app.app_id'
+Then retry this command with the returned app_id.`,
+ )
+ }
+ return nil
+}
+
// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied
// --output path. The authoritative guard is the local FileIO layer
// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and
diff --git a/shortcuts/apps/git_credential.go b/shortcuts/apps/git_credential.go
index 7b0ea606d..7a1804beb 100644
--- a/shortcuts/apps/git_credential.go
+++ b/shortcuts/apps/git_credential.go
@@ -75,6 +75,7 @@ var AppsGitCredentialInit = common.Shortcut{
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
+ "return commit_author_name and commit_author_email for repo-local git identity",
}).
Params(gitCredentialIssueParams(appID))
},
@@ -90,6 +91,12 @@ var AppsGitCredentialInit = common.Shortcut{
"repository_url": result.GitHTTPURL,
"status": initStatus(result),
}
+ if result.CommitAuthorName != "" {
+ payload["commit_author_name"] = result.CommitAuthorName
+ }
+ if result.CommitAuthorEmail != "" {
+ payload["commit_author_email"] = result.CommitAuthorEmail
+ }
if result.ConfigWarning != "" {
payload["git_config_warning"] = result.ConfigWarning
}
@@ -461,11 +468,13 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
}
}
issued := &gitcred.IssuedCredential{
- AppID: firstString(source, "app_id", appID),
- GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
- Username: firstString(source, "username"),
- PAT: firstString(source, "token", "Token", "pat", "password"),
- ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
+ AppID: firstString(source, "app_id", appID),
+ GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
+ Username: firstString(source, "username"),
+ PAT: firstString(source, "token", "Token", "pat", "password"),
+ ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
+ CommitAuthorName: firstString(source, "commit_author_name"),
+ CommitAuthorEmail: firstString(source, "commit_author_email"),
}
if issued.AppID == "" {
issued.AppID = appID
diff --git a/shortcuts/apps/git_credential_test.go b/shortcuts/apps/git_credential_test.go
index 5c5c0c1d2..7aa0d58be 100644
--- a/shortcuts/apps/git_credential_test.go
+++ b/shortcuts/apps/git_credential_test.go
@@ -87,6 +87,7 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
+ "return commit_author_name and commit_author_email for repo-local git identity",
})
}
diff --git a/shortcuts/apps/gitcred/helper.go b/shortcuts/apps/gitcred/helper.go
index 8610a4018..5ba27f2d4 100644
--- a/shortcuts/apps/gitcred/helper.go
+++ b/shortcuts/apps/gitcred/helper.go
@@ -129,7 +129,13 @@ func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string
if previous != nil && previous.PATRef != "" && previous.PATRef != ref {
_ = m.Secrets.Remove(previous.PATRef)
}
- result := &InitResult{AppID: appID, GitHTTPURL: url, Refreshed: previous != nil}
+ result := &InitResult{
+ AppID: appID,
+ GitHTTPURL: url,
+ Refreshed: previous != nil,
+ CommitAuthorName: issued.CommitAuthorName,
+ CommitAuthorEmail: issued.CommitAuthorEmail,
+ }
if m.GitConfig != nil {
if err := m.GitConfig.SetHelper(ctx, url, appID); err != nil {
result.ConfigWarning = err.Error()
diff --git a/shortcuts/apps/gitcred/types.go b/shortcuts/apps/gitcred/types.go
index b078b8ba8..12b6bc6ea 100644
--- a/shortcuts/apps/gitcred/types.go
+++ b/shortcuts/apps/gitcred/types.go
@@ -51,18 +51,22 @@ type CredentialRecord struct {
}
type IssuedCredential struct {
- AppID string
- GitHTTPURL string
- Username string
- PAT string
- ExpiresAt int64
+ AppID string
+ GitHTTPURL string
+ Username string
+ PAT string
+ ExpiresAt int64
+ CommitAuthorName string
+ CommitAuthorEmail string
}
type InitResult struct {
- AppID string
- GitHTTPURL string
- Refreshed bool
- ConfigWarning string
+ AppID string
+ GitHTTPURL string
+ Refreshed bool
+ ConfigWarning string
+ CommitAuthorName string
+ CommitAuthorEmail string
}
type RemoveResult struct {
diff --git a/shortcuts/apps/html_publish_client.go b/shortcuts/apps/html_publish_client.go
deleted file mode 100644
index 1efd627a6..000000000
--- a/shortcuts/apps/html_publish_client.go
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (c) 2026 Lark Technologies Pte. Ltd.
-// SPDX-License-Identifier: MIT
-
-package apps
-
-import (
- "bytes"
- "context"
- "fmt"
- "net/http"
-
- larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
-
- "github.com/larksuite/cli/errs"
- "github.com/larksuite/cli/internal/client"
- "github.com/larksuite/cli/internal/validate"
- "github.com/larksuite/cli/shortcuts/common"
-)
-
-type htmlPublishResponse struct {
- URL string
-}
-
-type appsHTMLPublishClient interface {
- HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error)
-}
-
-type appsHTMLPublishAPI struct {
- runtime *common.RuntimeContext
-}
-
-func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
- fd := larkcore.NewFormdata()
- fd.AddFile("file", bytes.NewReader(tarball.Body))
-
- apiResp, err := api.runtime.DoAPI(&larkcore.ApiReq{
- HttpMethod: http.MethodPost,
- ApiPath: fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID)),
- Body: fd,
- }, larkcore.WithFileUpload())
- if err != nil {
- return nil, client.WrapDoAPIError(err)
- }
- data, err := api.runtime.ClassifyAPIResponse(apiResp)
- if err != nil {
- return nil, enrichHTMLPublishAPIError(err)
- }
- url, _ := data["url"].(string)
- if url == "" {
- return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
- "html-publish response is missing the published app url")
- }
- return &htmlPublishResponse{URL: url}, nil
-}
-
-// OAPI business error codes returned by the
-// /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend
-// service; update when new codes are documented in the OAPI spec.
-const (
- errCodeBuildFailed = 90001 // tar.gz uploaded but server-side build failed
- errCodeAppNotFound = 90002 // app_id unknown or caller lacks permission
-)
-
-func buildHTMLPublishFailureHint(code int) string {
- switch code {
- case errCodeBuildFailed:
- return "server-side build failed: run `lark-cli apps +html-publish --app-id --path --dry-run` to inspect the packaged file list"
- case errCodeAppNotFound:
- return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
- default:
- return ""
- }
-}
diff --git a/shortcuts/apps/html_publish_client_test.go b/shortcuts/apps/html_publish_client_test.go
deleted file mode 100644
index 998b46220..000000000
--- a/shortcuts/apps/html_publish_client_test.go
+++ /dev/null
@@ -1,197 +0,0 @@
-// Copyright (c) 2026 Lark Technologies Pte. Ltd.
-// SPDX-License-Identifier: MIT
-
-package apps
-
-import (
- "bytes"
- "context"
- "mime"
- "mime/multipart"
- "strings"
- "testing"
-
- "github.com/larksuite/cli/errs"
- "github.com/larksuite/cli/internal/cmdutil"
- "github.com/larksuite/cli/internal/core"
- "github.com/larksuite/cli/internal/httpmock"
- "github.com/larksuite/cli/shortcuts/common"
-)
-
-func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
- t.Helper()
- t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
- cfg := &core.CliConfig{
- AppID: "test-app-" + strings.ToLower(t.Name()),
- AppSecret: "test-secret",
- Brand: core.BrandFeishu,
- UserOpenId: "ou_test",
- }
- factory, _, _, reg := cmdutil.TestFactory(t, cfg)
- rctx := common.TestNewRuntimeContextForAPI(context.Background(), nil, cfg, factory, core.AsUser)
- return rctx, reg
-}
-
-func TestAppsHTMLPublishAPI_Success(t *testing.T) {
- rctx, reg := newAppsClientRuntime(t)
- stub := &httpmock.Stub{
- Method: "POST",
- URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
- Body: map[string]interface{}{
- "code": 0,
- "msg": "success",
- "data": map[string]interface{}{
- "url": "https://miaoda.feishu.cn/app/app_x",
- },
- },
- }
- reg.Register(stub)
-
- api := appsHTMLPublishAPI{runtime: rctx}
- tarball := &htmlPublishTarball{Body: []byte("fake"), Size: 4, SHA256: "abc"}
- resp, err := api.HTMLPublish(context.Background(), "app_x", tarball)
- if err != nil {
- t.Fatalf("err=%v", err)
- }
- if resp.URL != "https://miaoda.feishu.cn/app/app_x" {
- t.Fatalf("url=%q", resp.URL)
- }
-
- ct := stub.CapturedHeaders.Get("Content-Type")
- mt, params, err := mime.ParseMediaType(ct)
- if err != nil || mt != "multipart/form-data" {
- t.Fatalf("content type %q wrong", ct)
- }
- mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
- saw := false
- for {
- p, err := mr.NextPart()
- if err != nil {
- break
- }
- if p.FormName() == "file" {
- saw = true
- }
- }
- if !saw {
- t.Fatalf("multipart missing 'file' part")
- }
-}
-
-func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
- rctx, reg := newAppsClientRuntime(t)
- reg.Register(&httpmock.Stub{
- Method: "POST",
- URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
- Body: map[string]interface{}{
- "code": 90001,
- "msg": "build failed: dependency conflict",
- },
- })
-
- api := appsHTMLPublishAPI{runtime: rctx}
- _, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
- if err == nil {
- t.Fatalf("expected error")
- }
- problem := requireAppsAPIProblem(t, err)
- if problem.Code != errCodeBuildFailed {
- t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
- }
- if problem.Hint == "" {
- t.Fatalf("expected non-empty hint on code 90001")
- }
- if !strings.Contains(problem.Message, "build failed") {
- t.Fatalf("missing failure message: %v", problem.Message)
- }
-}
-
-func TestAppsHTMLPublishAPI_AppNotFoundClassified(t *testing.T) {
- rctx, reg := newAppsClientRuntime(t)
- reg.Register(&httpmock.Stub{
- Method: "POST",
- URL: "/open-apis/spark/v1/apps/app_missing/upload_and_release_html_code",
- Body: map[string]interface{}{
- "code": errCodeAppNotFound,
- "msg": "app not found",
- },
- })
-
- api := appsHTMLPublishAPI{runtime: rctx}
- _, err := api.HTMLPublish(context.Background(), "app_missing", &htmlPublishTarball{Body: []byte("fake")})
- problem := requireAppsAPIProblem(t, err)
- if problem.Subtype != errs.SubtypeNotFound {
- t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
- }
- if problem.Hint == "" {
- t.Fatalf("expected app-not-found recovery hint")
- }
-}
-
-func TestAppsHTMLPublishAPI_MissingURLIsInvalidResponse(t *testing.T) {
- rctx, reg := newAppsClientRuntime(t)
- reg.Register(&httpmock.Stub{
- Method: "POST",
- URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
- Body: map[string]interface{}{
- "code": 0,
- "msg": "success",
- "data": map[string]interface{}{},
- },
- })
-
- api := appsHTMLPublishAPI{runtime: rctx}
- _, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
- problem := requireAppsProblem(t, err, errs.CategoryInternal)
- if problem.Subtype != errs.SubtypeInvalidResponse {
- t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
- }
-}
-
-func TestBuildHTMLPublishFailureHint_UnknownCodeReturnsEmpty(t *testing.T) {
- // 默认分支:未识别的 code 返回空 hint,让 Agent 用 message 兜底。
- if hint := buildHTMLPublishFailureHint(99999); hint != "" {
- t.Fatalf("unknown code should return empty hint, got %q", hint)
- }
- if hint := buildHTMLPublishFailureHint(0); hint != "" {
- t.Fatalf("zero code should return empty hint, got %q", hint)
- }
-}
-
-func TestBuildHTMLPublishFailureHint_KnownCodes(t *testing.T) {
- if hint := buildHTMLPublishFailureHint(90001); hint == "" {
- t.Fatalf("code 90001 should return non-empty hint")
- }
- if hint := buildHTMLPublishFailureHint(90002); hint == "" {
- t.Fatalf("code 90002 should return non-empty hint")
- }
-}
-
-func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing.T) {
- hint := buildHTMLPublishFailureHint(90002)
- if hint == "" {
- t.Fatalf("code 90002 should return non-empty hint")
- }
- if strings.Contains(hint, "+list") {
- t.Fatalf("hint must not point at hidden +list command, got: %q", hint)
- }
- if !strings.Contains(hint, "app_id") {
- t.Fatalf("hint should reference app_id, got: %q", hint)
- }
-}
-
-func TestAppsHTMLPublishAPI_MalformedResponseIsInvalidResponse(t *testing.T) {
- rctx, reg := newAppsClientRuntime(t)
- reg.Register(&httpmock.Stub{
- Method: "POST",
- URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
- RawBody: []byte("{not json"),
- })
-
- api := appsHTMLPublishAPI{runtime: rctx}
- _, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
- problem := requireAppsProblem(t, err, errs.CategoryInternal)
- if problem.Subtype != errs.SubtypeInvalidResponse {
- t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
- }
-}
diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go
index 7ca70fae5..dc7a2f978 100644
--- a/shortcuts/base/base_dryrun_ops_test.go
+++ b/shortcuts/base/base_dryrun_ops_test.go
@@ -104,6 +104,22 @@ func TestDryRunFieldOps(t *testing.T) {
assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open")
+
+ autoNumberRT := newBaseTestRuntime(
+ map[string]string{
+ "base-token": "app_x",
+ "table-id": "tbl_1",
+ "field-id": "fld_1",
+ "json": `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`,
+ },
+ nil,
+ nil,
+ )
+ autoNumberDR := dryRunFieldUpdate(ctx, autoNumberRT)
+ assertDryRunContains(t, autoNumberDR, "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1", `"name":"编号"`, `"type":"auto_number"`, `"rules":[`, `"length":4`)
+ if out := autoNumberDR.Format(); strings.Contains(out, "auto_serial") || strings.Contains(out, "reformat_existing_records") || strings.Contains(out, "/open-apis/bitable/v1/") {
+ t.Fatalf("auto_number dry-run must stay on v3 field JSON, got:\n%s", out)
+ }
}
func TestDryRunRecordOps(t *testing.T) {
@@ -117,7 +133,7 @@ func TestDryRunRecordOps(t *testing.T) {
)
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
- listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
+ listFieldNamesAliasRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"field-names": {"Name", "Age"}},
nil,
diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go
index 532639de3..5faa3739e 100644
--- a/shortcuts/base/base_execute_test.go
+++ b/shortcuts/base/base_execute_test.go
@@ -81,6 +81,37 @@ func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes
return parent.ExecuteContext(context.Background())
}
+func assertInvalidArgumentValidation(t *testing.T, err error, wantParam string, wantParams []string, messageContains string) {
+ t.Helper()
+ if err == nil {
+ t.Fatal("expected invalid-argument validation error, got nil")
+ }
+ p, ok := errs.ProblemOf(err)
+ if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("expected invalid-argument validation problem, got %T %v", err, err)
+ }
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("expected ValidationError, got %T %v", err, err)
+ }
+ if validationErr.Param != wantParam {
+ t.Fatalf("param=%q, want %q", validationErr.Param, wantParam)
+ }
+ if wantParams != nil {
+ if len(validationErr.Params) != len(wantParams) {
+ t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
+ }
+ for i, want := range wantParams {
+ if validationErr.Params[i].Name != want {
+ t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
+ }
+ }
+ }
+ if messageContains != "" && !strings.Contains(err.Error(), messageContains) {
+ t.Fatalf("err=%v, want message containing %q", err, messageContains)
+ }
+}
+
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
@@ -122,7 +153,7 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
if grant["user_open_id"] != "ou_testuser" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
}
- if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
+ if grant["message"] != "Granted the current CLI user full_access on the new base." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
@@ -469,9 +500,6 @@ func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testi
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
- if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
- t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
- }
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
@@ -577,8 +605,9 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
- if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
- t.Fatalf("stdout=%s", got)
+ wantDesc := "After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
+ if got := stdout.String(); !strings.Contains(got, wantDesc) {
+ t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
}
})
@@ -587,8 +616,9 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
- if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
- t.Fatalf("stdout=%s", got)
+ wantDesc := "After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
+ if got := stdout.String(); !strings.Contains(got, wantDesc) {
+ t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
}
})
@@ -597,7 +627,7 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
- if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
+ if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access") {
t.Fatalf("stdout=%s", got)
}
})
@@ -819,8 +849,189 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
- if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) {
- t.Fatalf("stdout=%s", got)
+ got := stdout.String()
+ for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("stdout missing %q:\n%s", want, got)
+ }
+ }
+}
+
+func TestFieldUpdateResultAlwaysRecommendsReadback(t *testing.T) {
+ tests := []struct {
+ name string
+ field interface{}
+ submitted map[string]interface{}
+ hintContains []string
+ }{
+ {
+ name: "direct complex server type overrides simple submitted type",
+ field: map[string]interface{}{"type": "auto_number"},
+ submitted: map[string]interface{}{"type": "number"},
+ hintContains: []string{`submitted type "number"`, `server returned type "auto_number"`},
+ },
+ {
+ name: "nested simple server type still recommends readback",
+ field: map[string]interface{}{"field": map[string]interface{}{"type": "number"}},
+ submitted: map[string]interface{}{"type": "auto_number"},
+ hintContains: []string{`submitted type "auto_number"`, `server returned type "number"`},
+ },
+ {
+ name: "submitted simple type still recommends readback when response omits type",
+ field: map[string]interface{}{"id": "fld_x"},
+ submitted: map[string]interface{}{"type": "text"},
+ hintContains: []string{`type "text"`, "cannot determine the previous type"},
+ },
+ {
+ name: "missing type is conservative",
+ field: map[string]interface{}{"id": "fld_x"},
+ submitted: map[string]interface{}{"name": "Amount"},
+ hintContains: []string{"unknown or uncommon field type", "+field-get"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := fieldUpdateResult(map[string]interface{}{"field": tc.field, "updated": true}, tc.submitted)
+ if got["field_get_recommended"] != true || got["next_step"] != "field_get" {
+ t.Fatalf("result=%#v, want readback recommendation", got)
+ }
+ hint, _ := got["verification_hint"].(string)
+ for _, want := range tc.hintContains {
+ if !strings.Contains(hint, want) {
+ t.Fatalf("verification_hint=%q, want substring %q", hint, want)
+ }
+ }
+ })
+ }
+}
+
+func TestBaseFieldExecuteUpdateNoopReturnsAPIError(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ reg.Register(&httpmock.Stub{
+ Method: "PUT",
+ URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
+ Body: map[string]interface{}{
+ "code": 800070003,
+ "msg": "no operation produced",
+ },
+ })
+ err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout)
+ if err == nil {
+ t.Fatal("expected the API no-op response to surface as an error, got nil")
+ }
+ p, ok := errs.ProblemOf(err)
+ if !ok {
+ t.Fatalf("expected a typed API error, got %T %v", err, err)
+ }
+ if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeUnknown || p.Code != 800070003 {
+ t.Fatalf("category/subtype/code=%s/%s/%d", p.Category, p.Subtype, p.Code)
+ }
+ var apiErr *errs.APIError
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("expected APIError, got %T %v", err, err)
+ }
+ if got := stdout.String(); strings.TrimSpace(got) != "" {
+ t.Fatalf("no success envelope should be emitted on a no-op API error:\n%s", got)
+ }
+}
+
+func TestBaseFieldExecuteUpdateAutoNumberUsesV3FieldJSON(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ stub := &httpmock.Stub{
+ Method: "PUT",
+ URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "field": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
+ },
+ },
+ }
+ reg.Register(stub)
+ jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`
+ if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
+ t.Fatalf("err=%v", err)
+ }
+ gotBody := string(stub.CapturedBody)
+ for _, want := range []string{
+ `"name":"编号"`,
+ `"type":"auto_number"`,
+ `"rules":[`,
+ `"date_format":"yyyyMM"`,
+ `"length":4`,
+ } {
+ if !strings.Contains(gotBody, want) {
+ t.Fatalf("request body missing %q:\n%s", want, gotBody)
+ }
+ }
+ for _, forbidden := range []string{"auto_serial", "reformat_existing_records", `"type":1005`} {
+ if strings.Contains(gotBody, forbidden) {
+ t.Fatalf("request body must not contain v1 field %q:\n%s", forbidden, gotBody)
+ }
+ }
+ got := stdout.String()
+ for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("stdout missing %q:\n%s", want, got)
+ }
+ }
+ for _, forbidden := range []string{`"reformat_existing_records"`} {
+ if strings.Contains(got, forbidden) {
+ t.Fatalf("stdout must not expose %q:\n%s", forbidden, got)
+ }
+ }
+}
+
+func TestBaseFieldExecuteUpdateDoesNotRejectExtraJSONKeys(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ stub := &httpmock.Stub{
+ Method: "PUT",
+ URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
+ },
+ }
+ reg.Register(stub)
+ // Unknown v3 keys are forwarded unchanged; the server remains the source of
+ // truth for whether a field-update property is supported.
+ jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"incremental_number","length":4}]},"reformat_existing_records":true}`
+ if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
+ t.Fatalf("err=%v", err)
+ }
+ if gotBody := string(stub.CapturedBody); !strings.Contains(gotBody, `"reformat_existing_records":true`) {
+ t.Fatalf("request body must preserve unknown v3 key:\n%s", gotBody)
+ }
+ if got := stdout.String(); !strings.Contains(got, `"updated": true`) {
+ t.Fatalf("expected successful update, got: %s", got)
+ }
+}
+
+func TestBaseFieldValidateAllowsRatingMaxAboveLimit(t *testing.T) {
+ ctx := context.Background()
+ tests := []struct {
+ name string
+ shortcut common.Shortcut
+ runtime *common.RuntimeContext
+ }{
+ {
+ name: "create",
+ shortcut: BaseFieldCreate,
+ runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
+ },
+ {
+ name: "update",
+ shortcut: BaseFieldUpdate,
+ runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "field-id": "fld_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if err := tc.shortcut.Validate(ctx, tc.runtime); err != nil {
+ t.Fatalf("rating max above 10 should not be blocked by CLI validation: %v", err)
+ }
+ })
}
}
@@ -1092,8 +1303,32 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Status","type":"text"}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
- if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"fld_new"`) {
- t.Fatalf("stdout=%s", got)
+ got := stdout.String()
+ for _, want := range []string{`"created": true`, `"fld_new"`, `"field_get_recommended": false`, `"next_step": "done"`, `"verification_hint"`} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("stdout missing %q:\n%s", want, got)
+ }
+ }
+ })
+
+ t.Run("create generated field recommends readback", func(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"id": "fld_auto", "name": "编号", "type": "auto_number"},
+ },
+ })
+ if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"编号","type":"auto_number"}`}, factory, stdout); err != nil {
+ t.Fatalf("err=%v", err)
+ }
+ got := stdout.String()
+ for _, want := range []string{`"created": true`, `"fld_auto"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("stdout missing %q:\n%s", want, got)
+ }
}
})
@@ -1140,11 +1375,58 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
if len(fields) != 2 {
t.Fatalf("fields len=%d output=%#v", len(fields), data)
}
+ if data["field_get_recommended"] != false || data["next_step"] != "done" || data["verification_hint"] == nil {
+ t.Fatalf("simple batch create must carry field_get_recommended:false + next_step:done + verification_hint: %#v", data)
+ }
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
}
})
+ t.Run("create array with generated field recommends readback", func(t *testing.T) {
+ oldDelay := fieldCreateBatchDelay
+ fieldCreateBatchDelay = 0
+ t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
+
+ factory, stdout, reg := newExecuteFactory(t)
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
+ BodyFilter: func(body []byte) bool {
+ return strings.Contains(string(body), `"name":"Title"`)
+ },
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"},
+ },
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
+ BodyFilter: func(body []byte) bool {
+ return strings.Contains(string(body), `"name":"编号"`)
+ },
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"id": "fld_no", "name": "编号", "type": "auto_number"},
+ },
+ })
+
+ if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"Title","type":"text"},{"name":"编号","type":"auto_number"}]`}, factory, stdout); err != nil {
+ t.Fatalf("err=%v", err)
+ }
+ data := decodeBaseEnvelope(t, stdout)
+ if data["created"] != true || data["total"] != float64(2) {
+ t.Fatalf("unexpected output: %#v", data)
+ }
+ if _, ok := data["fields"].([]interface{}); !ok {
+ t.Fatalf("batch create must keep fields array: %#v", data)
+ }
+ if data["field_get_recommended"] != true || data["next_step"] != "field_get" || data["verification_hint"] == nil {
+ t.Fatalf("batch with auto_number must recommend readback: %#v", data)
+ }
+ })
+
t.Run("delete", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1319,6 +1601,32 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
+ t.Run("list field names alias preserves quoted commas and at-sign names", func(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "field_id=A%2CB&field_id=%40Owner&limit=1&offset=0",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "fields": []interface{}{"A,B", "@Owner"},
+ "record_id_list": []interface{}{"rec_alias_special"},
+ "data": []interface{}{[]interface{}{"value-1", "value-2"}},
+ "total": 1,
+ },
+ },
+ })
+ if err := runShortcut(t, BaseRecordList, []string{
+ "+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1",
+ "--field-names", `"A,B",@Owner`, "--format", "json",
+ }, factory, stdout); err != nil {
+ t.Fatalf("err=%v", err)
+ }
+ if got := stdout.String(); !strings.Contains(got, `"rec_alias_special"`) {
+ t.Fatalf("stdout=%s", got)
+ }
+ })
+
t.Run("list json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1615,28 +1923,162 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
- t.Run("list legacy fields flag rejected", func(t *testing.T) {
- factory, stdout, _ := newExecuteFactory(t)
- err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
- if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
+ t.Run("list fields alias accepts JSON array projection", func(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "field_id=Name&field_id=Age&limit=1&offset=0",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "fields": []interface{}{"Name", "Age"},
+ "record_id_list": []interface{}{"rec_fields"},
+ "data": []interface{}{[]interface{}{"Alice", 18}},
+ "total": 1,
+ },
+ },
+ })
+ if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--fields", `["Name","Age"]`, "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
+ if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
+ t.Fatalf("stdout=%s", got)
+ }
})
- t.Run("list field ids and field names alias are mutually exclusive", func(t *testing.T) {
- factory, stdout, _ := newExecuteFactory(t)
- err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "Name", "--field-names", "Age"}, factory, stdout)
- if err == nil || !strings.Contains(err.Error(), "--field-id and --field-names are mutually exclusive") {
+ t.Run("list field names alias accepts repeated projection", func(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "field_id=Name&field_id=Age&limit=1&offset=0",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "fields": []interface{}{"Name", "Age"},
+ "record_id_list": []interface{}{"rec_fields"},
+ "data": []interface{}{[]interface{}{"Alice", 18}},
+ "total": 1,
+ },
+ },
+ })
+ if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name", "--field-names", "Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
+ if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
+ t.Fatalf("stdout=%s", got)
+ }
})
- t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
+ t.Run("list projection aliases report only supplied ambiguous inputs", func(t *testing.T) {
+ baseArgs := []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}
+ cases := []struct {
+ name string
+ args []string
+ wantParam string
+ wantParams []string
+ }{
+ {name: "canonical and fields alias", args: []string{"--field-id", "Name", "--fields", `["Age"]`}, wantParam: "--field-id", wantParams: []string{"--field-id", "--fields"}},
+ {name: "canonical and field names alias", args: []string{"--field-id", "Name", "--field-names", "Age"}, wantParam: "--field-id", wantParams: []string{"--field-id", "--field-names"}},
+ {name: "compatibility aliases", args: []string{"--fields", `["Name"]`, "--field-names", "Age"}, wantParam: "--fields", wantParams: []string{"--fields", "--field-names"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ factory, stdout, _ := newExecuteFactory(t)
+ args := append(append([]string{}, baseArgs...), tc.args...)
+ err := runShortcut(t, BaseRecordList, args, factory, stdout)
+ assertInvalidArgumentValidation(t, err, tc.wantParam, tc.wantParams, "mutually exclusive")
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) || validationErr.Hint != "Use only --field-id for projection." {
+ t.Fatalf("hint=%q, want canonical projection guidance", validationErr.Hint)
+ }
+ })
+ }
+ })
+
+ t.Run("search json conflict reports each supplied projection parameter", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
- err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
- if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
+ err := runShortcut(t, BaseRecordSearch, []string{
+ "+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
+ "--json", `{"keyword":"Alice","search_fields":["Name"]}`,
+ "--field-names", "Age",
+ }, factory, stdout)
+ assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-names"}, "mutually exclusive")
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) || !strings.Contains(validationErr.Hint, "inside --json") {
+ t.Fatalf("hint=%q, want JSON-body guidance", validationErr.Hint)
+ }
+ })
+
+ t.Run("list canonical and alias projections reject duplicates consistently", func(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ param string
+ }{
+ {name: "canonical", args: []string{"--field-id", "Cost--USD", "--field-id", "Cost--USD"}, param: "--field-id"},
+ {name: "fields alias", args: []string{"--fields", `["Cost--USD","Cost--USD"]`}, param: "--fields"},
+ {name: "field names alias", args: []string{"--field-names", "Cost--USD", "--field-names", "Cost--USD"}, param: "--field-names"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ factory, stdout, _ := newExecuteFactory(t)
+ args := append([]string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}, tc.args...)
+ err := runShortcut(t, BaseRecordList, args, factory, stdout)
+ assertInvalidArgumentValidation(t, err, tc.param, []string{tc.param}, "duplicate field id")
+ })
+ }
+ })
+
+ t.Run("search fields alias accepts JSON array projection", func(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ searchStub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "fields": []interface{}{"Name", "Age"},
+ "record_id_list": []interface{}{"rec_search"},
+ "data": []interface{}{[]interface{}{"Alice", 18}},
+ },
+ },
+ }
+ reg.Register(searchStub)
+ if err := runShortcut(t, BaseRecordSearch, []string{
+ "+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
+ "--keyword", "Alice", "--search-field", "Name", "--fields", `["Name","Age"]`, "--format", "json",
+ }, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
+ if body := string(searchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
+ t.Fatalf("captured body=%s", body)
+ }
+ })
+
+ t.Run("get field names alias accepts repeated projection", func(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ batchStub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "record_id_list": []interface{}{"rec_1"},
+ "fields": []interface{}{"Name", "Age"},
+ "data": []interface{}{[]interface{}{"Alice", 18}},
+ },
+ },
+ }
+ reg.Register(batchStub)
+ if err := runShortcut(t, BaseRecordGet, []string{
+ "+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1",
+ "--field-names", "Name", "--field-names", "Age", "--format", "json",
+ }, factory, stdout); err != nil {
+ t.Fatalf("err=%v", err)
+ }
+ if body := string(batchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
+ t.Fatalf("request body=%s", body)
+ }
})
t.Run("get", func(t *testing.T) {
@@ -1993,16 +2435,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
- "fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1", "rec_2"},
- "data": []interface{}{[]interface{}{"Alice"}, []interface{}{"Bob"}},
},
},
})
- if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":["Name"],"rows":[["Alice"],["Bob"]]}`}, factory, stdout); err != nil {
+ if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"create_records":[{"Name":"Alice"},{"Name":"Bob"}]}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
- if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"Alice"`) {
+ if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
t.Fatalf("stdout=%s", got)
}
})
@@ -2015,16 +2455,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
- "has_more": false,
- "record_id_list": []interface{}{"rec_1"},
- "update": map[string]interface{}{"Status": "Done"},
+ "ignored_fields": []interface{}{"Formula"},
},
},
})
- if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Status":"Done"}}`}, factory, stdout); err != nil {
+ if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"update_records":{"rec_1":{"Status":["Done"]}}}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
- if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"update"`) || !strings.Contains(got, `"Done"`) {
+ if got := stdout.String(); !strings.Contains(got, `"ignored_fields"`) || !strings.Contains(got, `"Formula"`) {
t.Fatalf("stdout=%s", got)
}
})
@@ -2036,20 +2474,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update",
Body: map[string]interface{}{
"code": 0,
- "data": map[string]interface{}{
- "record_id_list": []interface{}{"rec_1"},
- },
+ "data": map[string]interface{}{},
},
}
reg.Register(updateStub)
- if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Name":"Alice","Status":"Done"}}`}, factory, stdout); err != nil {
+ input := `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`
+ if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", input}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
- if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
- t.Fatalf("stdout=%s", got)
- }
body := string(updateStub.CapturedBody)
- if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"patch":{"Name":"Alice","Status":"Done"}`) {
+ if !strings.Contains(body, `"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}`) {
t.Fatalf("request body=%s", body)
}
})
diff --git a/shortcuts/base/base_form_submit.go b/shortcuts/base/base_form_submit.go
index e23284954..af1d2adc8 100644
--- a/shortcuts/base/base_form_submit.go
+++ b/shortcuts/base/base_form_submit.go
@@ -26,7 +26,7 @@ var BaseFormSubmit = common.Shortcut{
Service: "base",
Command: "+form-submit",
Description: "Submit a form (fill and submit form data)",
- Risk: "write",
+ Risk: "high-risk-write",
Scopes: []string{"base:form:update", "docs:document.media:upload"},
AuthTypes: authTypes(),
HasFormat: true,
@@ -39,6 +39,7 @@ var BaseFormSubmit = common.Shortcut{
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
+ baseHighRiskYesTip,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFormSubmit(runtime)
diff --git a/shortcuts/base/base_ops.go b/shortcuts/base/base_ops.go
index 68bbf795b..6c3dcacf7 100644
--- a/shortcuts/base/base_ops.go
+++ b/shortcuts/base/base_ops.go
@@ -29,7 +29,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
Body(buildBaseCopyBody(runtime)).
Set("base_token", runtime.Str("base-token"))
if runtime.IsBot() {
- d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
+ d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
}
return d
}
@@ -37,7 +37,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
if runtime.IsBot() {
- d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
+ d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
}
d.
POST("/open-apis/base/v3/bases").
diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go
index 56b3b5c2a..78c1e1118 100644
--- a/shortcuts/base/base_shortcuts_test.go
+++ b/shortcuts/base/base_shortcuts_test.go
@@ -28,23 +28,16 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
}
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
- return newBaseTestRuntimeWithArraysAndSlices(stringFlags, stringArrayFlags, nil, boolFlags, intFlags)
-}
-
-func newBaseTestRuntimeWithSlices(stringFlags map[string]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
- return newBaseTestRuntimeWithArraysAndSlices(stringFlags, nil, stringSliceFlags, boolFlags, intFlags)
-}
-
-func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, stringArrayFlags map[string][]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
for name := range stringFlags {
cmd.Flags().String(name, "", "")
}
for name := range stringArrayFlags {
- cmd.Flags().StringArray(name, nil, "")
- }
- for name := range stringSliceFlags {
- cmd.Flags().StringSlice(name, nil, "")
+ if name == "field-names" {
+ cmd.Flags().StringSlice(name, nil, "")
+ } else {
+ cmd.Flags().StringArray(name, nil, "")
+ }
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
@@ -61,11 +54,6 @@ func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, string
_ = cmd.Flags().Set(name, value)
}
}
- for name, values := range stringSliceFlags {
- for _, value := range values {
- _ = cmd.Flags().Set(name, value)
- }
- }
for name, value := range boolFlags {
if value {
_ = cmd.Flags().Set(name, "true")
@@ -477,6 +465,40 @@ func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
}
}
+func TestBaseRecordProjectionAliasesAreHidden(t *testing.T) {
+ tests := []struct {
+ name string
+ shortcut common.Shortcut
+ }{
+ {name: "record list", shortcut: BaseRecordList},
+ {name: "record search", shortcut: BaseRecordSearch},
+ {name: "record get", shortcut: BaseRecordGet},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ parent := &cobra.Command{Use: "base"}
+ tt.shortcut.Mount(parent, &cmdutil.Factory{})
+ cmd := parent.Commands()[0]
+
+ primary := cmd.Flags().Lookup("field-id")
+ if primary == nil || primary.Hidden {
+ t.Fatalf("public projection flag --field-id missing or hidden: %#v", primary)
+ }
+ help := cmd.Flags().FlagUsages()
+ for _, aliasName := range []string{"fields", "field-names"} {
+ alias := cmd.Flags().Lookup(aliasName)
+ if alias == nil || !alias.Hidden {
+ t.Fatalf("projection alias --%s should exist and be hidden: %#v", aliasName, alias)
+ }
+ if strings.Contains(help, "--"+aliasName) {
+ t.Fatalf("help should not include hidden --%s:\n%s", aliasName, help)
+ }
+ }
+ })
+ }
+}
+
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
@@ -779,14 +801,16 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
name: "record batch create json",
shortcut: BaseRecordBatchCreate,
wantHelp: []string{
- `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
+ "create_records contains one field map per record",
+ `{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
},
},
{
name: "record batch update json",
shortcut: BaseRecordBatchUpdate,
wantHelp: []string{
- `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
+ "update_records maps each record ID to its field map",
+ `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
},
},
}
@@ -822,9 +846,13 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
"does not auto-upsert by business key",
"use +field-list to confirm real writable fields",
"do not write system fields, formula, lookup, or attachment fields",
+ "Sub-record/child-record path",
+ "set that link field to a parent record reference array",
+ `{"Parent Link":[{"id":"rec_xxx"}]}`,
+ "do not look for parent_record_id or a separate child-record API",
"CellValue happy path: text/phone/url",
- "select -> \"Todo\"",
- "multi-select -> [\"Tag A\",\"Tag B\"]",
+ "select (multiple=false) -> \"Todo\"",
+ "select (multiple=true) -> [\"Tag A\",\"Tag B\"]",
"datetime -> \"2026-03-24 10:00:00\"",
"checkbox -> true/false",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
@@ -838,11 +866,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
name: "record batch create",
shortcut: BaseRecordBatchCreate,
wantTips: []string{
- "Happy path fields: fields is the column order",
- "rows is an array of row arrays",
- "may use null for empty cells",
+ "Happy path field: create_records",
+ "create_records is an array of independent record field maps",
+ `{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
"use +field-list to confirm real writable fields",
- "Batch create supports max 200 rows per call",
+ "Batch create supports max 200 records per call",
"do not immediately +record-list the same table",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
@@ -854,9 +882,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
name: "record batch update",
shortcut: BaseRecordBatchUpdate,
wantTips: []string{
- "Happy path fields: record_id_list is the target record IDs",
- "patch is a field map applied unchanged to every target record",
- "Do not use +record-batch-update for per-row different values",
+ "Happy path field: update_records",
+ "update_records maps each record ID to its own field map",
+ `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
+ "contains only optional ignored_fields",
+ "does not check whether record IDs exist",
"use +field-list to confirm real writable fields",
"Batch update supports max 200 records per call",
"CellValue happy path: text/phone/url",
@@ -970,11 +1000,17 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
+ if strings.Contains(help, "reformat-existing-records") {
+ t.Fatalf("+field-update must not expose a --reformat-existing-records flag:\n%s", help)
+ }
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
`lark-cli base +field-update --base-token --table-id --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
+ `Example auto_number update: lark-cli base +field-update`,
+ `When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers`,
+ "just submit the target field definition and do not add extra low-level parameters",
"full field-definition PUT semantics",
"Read the current field first with +field-get",
"Type conversion is allowlist-based",
@@ -987,6 +1023,9 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
+ if strings.Contains(tips, "--reformat-existing-records") {
+ t.Fatalf("+field-update tips must not ask agents to pass --reformat-existing-records:\n%s", tips)
+ }
}
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
@@ -1109,6 +1148,10 @@ func TestBaseFieldValidate(t *testing.T) {
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil {
t.Fatalf("formula update validate err=%v", err)
}
+ autoNumberJSON := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"incremental_number","length":4}]}}`
+ if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": autoNumberJSON}, nil, nil)); err != nil {
+ t.Fatalf("auto number update validate err=%v", err)
+ }
}
func TestBaseTableValidate(t *testing.T) {
@@ -1230,13 +1273,89 @@ func TestBaseRecordValidate(t *testing.T) {
)); err != nil {
t.Fatalf("record search json with sort-json validate err=%v", err)
}
- if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
+ err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
nil,
nil,
- )); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
- t.Fatalf("err=%v", err)
+ ))
+ assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--keyword"}, "mutually exclusive")
+ err = BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
+ map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "fields": "Name"},
+ map[string][]string{"field-id": {"fld_name"}},
+ nil,
+ nil,
+ ))
+ assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-id", "--fields"}, "mutually exclusive")
+}
+
+func TestBaseRecordSearchProjectionLimit(t *testing.T) {
+ ctx := context.Background()
+ fields := make([]string, 51)
+ for i := range fields {
+ fields[i] = "Field " + strconv.Itoa(i+1)
}
+
+ if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
+ map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
+ map[string][]string{"search-field": {"Name"}, "field-id": fields[:50]},
+ nil,
+ nil,
+ )); err != nil {
+ t.Fatalf("50 projection fields should be accepted: %v", err)
+ }
+
+ err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
+ map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
+ map[string][]string{"search-field": {"Name"}, "field-id": fields},
+ nil,
+ nil,
+ ))
+ assertInvalidArgumentValidation(t, err, "--field-id", []string{"--field-id"}, "maximum limit of 50")
+
+ body, marshalErr := json.Marshal(map[string]interface{}{
+ "keyword": "Alice",
+ "search_fields": []string{"Name"},
+ "select_fields": fields,
+ })
+ if marshalErr != nil {
+ t.Fatalf("marshal search body: %v", marshalErr)
+ }
+ err = BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
+ map[string]string{"base-token": "b", "table-id": "tbl_1", "json": string(body)},
+ nil,
+ nil,
+ ))
+ assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maximum limit of 50")
+}
+
+func TestRecordSearchJSONNullProjectionIsOmitted(t *testing.T) {
+ runtime := newBaseTestRuntime(map[string]string{
+ "json": `{"keyword":"Alice","search_fields":["Name"],"select_fields":null,"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
+ }, nil, nil)
+ body, err := recordSearchJSONBody(runtime)
+ if err != nil {
+ t.Fatalf("recordSearchJSONBody() error = %v", err)
+ }
+ if _, exists := body["select_fields"]; exists {
+ t.Fatalf("select_fields:null must normalize to omitted, body=%#v", body)
+ }
+ if sortConfig, ok := body["sort"].([]interface{}); !ok || len(sortConfig) != 1 {
+ t.Fatalf("sort normalization must continue after omitting null select_fields, body=%#v", body)
+ }
+}
+
+func TestBaseRecordSearchJSONProjectionParamIgnoresFlagLikeFieldNames(t *testing.T) {
+ ctx := context.Background()
+ err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
+ map[string]string{
+ "base-token": "b",
+ "table-id": "tbl_1",
+ "json": `{"keyword":"cost","search_fields":["Name"],"select_fields":["Cost--USD","Cost--USD"]}`,
+ },
+ nil,
+ nil,
+ ))
+ assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "duplicate field id")
}
func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {
@@ -1937,8 +2056,8 @@ func TestBaseFormSubmitShortcut(t *testing.T) {
if s.Service != "base" {
t.Fatalf("Service=%q want base", s.Service)
}
- if s.Risk != "write" {
- t.Fatalf("Risk=%q want write", s.Risk)
+ if s.Risk != "high-risk-write" {
+ t.Fatalf("Risk=%q want high-risk-write", s.Risk)
}
if !s.HasFormat {
t.Fatal("HasFormat should be true")
@@ -2238,6 +2357,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"+form-submit",
"--share-token", "shr_exec1",
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
+ "--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2306,6 +2426,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_exec6",
"--base-token", "bas_exec6",
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
+ "--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
@@ -2354,6 +2475,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_dedup",
"--base-token", "bas_dedup",
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
+ "--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2365,6 +2487,33 @@ func TestExecuteFormSubmit(t *testing.T) {
})
}
+// TestFormSubmitRequiresConfirmation pins the high-risk-write classification:
+// without --yes the runner's confirmation gate must fire before Execute runs,
+// returning a typed confirmation_required error and touching no API.
+func TestFormSubmitRequiresConfirmation(t *testing.T) {
+ if BaseFormSubmit.Risk != "high-risk-write" {
+ t.Fatalf("Risk=%q want high-risk-write", BaseFormSubmit.Risk)
+ }
+
+ factory, stdout, _ := newExecuteFactory(t)
+ args := []string{
+ "+form-submit",
+ "--share-token", "shr_confirm",
+ "--json", `{"fields":{"Rating":5}}`,
+ }
+ err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
+ if err == nil {
+ t.Fatal("expected confirmation_required error without --yes")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok {
+ t.Fatalf("expected typed error, got %T: %v", err, err)
+ }
+ if problem.Subtype != errs.SubtypeConfirmationRequired {
+ t.Fatalf("subtype=%q want %q", problem.Subtype, errs.SubtypeConfirmationRequired)
+ }
+}
+
func TestUploadAttachmentsParallel(t *testing.T) {
t.Run("single file upload via execute path", func(t *testing.T) {
tmpDir := t.TempDir()
@@ -2401,6 +2550,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_para1",
"--base-token", "bas_para1",
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
+ "--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2435,6 +2585,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_err",
"--base-token", "bas_err",
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
+ "--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go
index 403be0964..8285c542e 100644
--- a/shortcuts/base/field_ops.go
+++ b/shortcuts/base/field_ops.go
@@ -5,6 +5,7 @@ package base
import (
"context"
+ "fmt"
"strings"
"time"
@@ -36,7 +37,10 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
- bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
+ bodies, err := parseFieldCreateBodies(pc, runtime.Str("json"))
+ if err != nil {
+ return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
+ }
dr := common.NewDryRunAPI().
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime))
@@ -48,7 +52,10 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
- body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
+ body, err := parseJSONObject(pc, runtime.Str("json"), "json")
+ if err != nil {
+ return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
+ }
return common.NewDryRunAPI().
PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
Body(body).
@@ -166,10 +173,10 @@ func executeFieldCreate(runtime *common.RuntimeContext) error {
fields = append(fields, data)
}
if len(fields) == 1 {
- runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
+ runtime.Out(fieldCreateResult(map[string]interface{}{"field": fields[0], "created": true}, bodies[0]), nil)
return nil
}
- runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
+ runtime.Out(fieldCreateBatchResult(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, bodies), nil)
return nil
}
@@ -197,10 +204,101 @@ func executeFieldUpdate(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
- runtime.Out(map[string]interface{}{"field": data, "updated": true}, nil)
+ runtime.Out(fieldUpdateResult(map[string]interface{}{"field": data, "updated": true}, body), nil)
return nil
}
+func fieldCreateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
+ readbackRecommended, reason := fieldWriteReadbackRecommendation(submitted, "create")
+ return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
+}
+
+// fieldCreateBatchResult attaches the same top-level readback contract to a
+// multi-field create. It recommends +field-get when any submitted field is a
+// computed/linked/generated (or unknown) type, so agents know when to verify
+// server state without breaking the existing fields/total structure.
+func fieldCreateBatchResult(result map[string]interface{}, submitted []map[string]interface{}) map[string]interface{} {
+ recommend := false
+ reason := "simple fields created successfully; use +field-get only when extra properties or explicit verification are needed"
+ for _, body := range submitted {
+ if rec, r := fieldWriteReadbackRecommendation(body, "create"); rec {
+ recommend = true
+ reason = r
+ break
+ }
+ }
+ return attachFieldReadbackRecommendation(result, recommend, reason)
+}
+
+func fieldUpdateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
+ returnedType := normalizeFieldType(fieldResultType(result["field"]))
+ submittedType := normalizeFieldType(common.GetString(submitted, "type"))
+ readbackRecommended, reason := fieldUpdateReadbackRecommendation(returnedType, submittedType)
+ return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
+}
+
+func fieldUpdateReadbackRecommendation(returnedType, submittedType string) (bool, string) {
+ if returnedType != "" && submittedType != "" && returnedType != submittedType {
+ return true, fmt.Sprintf("field update submitted type %q but the server returned type %q; run +field-get and verify record values before declaring completion", submittedType, returnedType)
+ }
+
+ fieldType := returnedType
+ if fieldType == "" {
+ fieldType = submittedType
+ }
+ if recommended, reason := fieldTypeReadbackRecommendation(fieldType, "update"); recommended {
+ return true, reason + "; sample record values when generated, computed, or converted values are in scope"
+ }
+ return true, fmt.Sprintf("field update request succeeded for type %q, but +field-update cannot determine the previous type; run +field-get and sample record values if the type changed before declaring completion", fieldType)
+}
+
+func attachFieldReadbackRecommendation(result map[string]interface{}, readbackRecommended bool, reason string) map[string]interface{} {
+ result["field_get_recommended"] = readbackRecommended
+ result["verification_hint"] = reason
+ if readbackRecommended {
+ result["next_step"] = "field_get"
+ } else {
+ result["next_step"] = "done"
+ }
+ return result
+}
+
+func fieldWriteReadbackRecommendation(submitted map[string]interface{}, operation string) (bool, string) {
+ fieldType := normalizeFieldType(common.GetString(submitted, "type"))
+ return fieldTypeReadbackRecommendation(fieldType, operation)
+}
+
+func fieldTypeReadbackRecommendation(fieldType, operation string) (bool, string) {
+ fieldType = normalizeFieldType(fieldType)
+ switch fieldType {
+ case "formula", "lookup", "auto_number", "link":
+ return true, fmt.Sprintf("computed, linked, or generated field %s should be verified with +field-get before declaring completion", operation)
+ case "text", "number", "select", "datetime", "checkbox", "user", "group_chat", "attachment", "location":
+ return false, fmt.Sprintf("simple field %s returned successfully; use +field-get only when extra properties or explicit verification are needed", operation)
+ default:
+ return true, "unknown or uncommon field type; run +field-get to avoid assuming the submitted JSON fully describes server state"
+ }
+}
+
+func normalizeFieldType(fieldType string) string {
+ return strings.ToLower(strings.TrimSpace(fieldType))
+}
+
+func fieldResultType(value interface{}) string {
+ field, ok := value.(map[string]interface{})
+ if !ok {
+ return ""
+ }
+ if fieldType := strings.ToLower(strings.TrimSpace(common.GetString(field, "type"))); fieldType != "" {
+ return fieldType
+ }
+ nested, ok := field["field"].(map[string]interface{})
+ if !ok {
+ return ""
+ }
+ return strings.ToLower(strings.TrimSpace(common.GetString(nested, "type")))
+}
+
func executeFieldDelete(runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
tableIDValue := baseTableID(runtime)
diff --git a/shortcuts/base/field_search_options.go b/shortcuts/base/field_search_options.go
index ad9c47145..36080b06b 100644
--- a/shortcuts/base/field_search_options.go
+++ b/shortcuts/base/field_search_options.go
@@ -27,7 +27,7 @@ var BaseFieldSearchOptions = common.Shortcut{
},
Tips: []string{
`Example: lark-cli base +field-search-options --base-token --table-id --field-id "Status" --keyword "Do"`,
- "Use only for fields with options, such as select or multi-select fields.",
+ "Use only for select fields, whether multiple is false or true.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateLimitPageSizeAlias(runtime); err != nil {
diff --git a/shortcuts/base/field_update.go b/shortcuts/base/field_update.go
index 177ad7e9a..71aaba7b6 100644
--- a/shortcuts/base/field_update.go
+++ b/shortcuts/base/field_update.go
@@ -27,7 +27,9 @@ var BaseFieldUpdate = common.Shortcut{
baseHighRiskYesTip,
`Example text: lark-cli base +field-update --base-token --table-id --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`Example select: lark-cli base +field-update --base-token --table-id --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
+ `Example auto_number update: lark-cli base +field-update --base-token --table-id --field-id "编号" --json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' --yes`,
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
+ `When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers; just submit the target field definition and do not add extra low-level parameters.`,
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
"Formula and lookup updates require reading the corresponding guide first.",
"Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.",
diff --git a/shortcuts/base/helpers_test.go b/shortcuts/base/helpers_test.go
index a49f0efc4..0f1a838d2 100644
--- a/shortcuts/base/helpers_test.go
+++ b/shortcuts/base/helpers_test.go
@@ -238,14 +238,14 @@ func TestRecordSelectionHelpers(t *testing.T) {
t.Fatalf("err=%v", err)
}
- fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
+ fields, err = resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Name"}})
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
t.Fatalf("fields=%v err=%v", fields, err)
}
- if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
+ if _, err := resolveRecordGetSelectFields([]string{"Name"}, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
- if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
+ if _, err := resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("err=%v", err)
}
diff --git a/shortcuts/base/record_batch_create.go b/shortcuts/base/record_batch_create.go
index 8f57753d8..e6ec24af8 100644
--- a/shortcuts/base/record_batch_create.go
+++ b/shortcuts/base/record_batch_create.go
@@ -19,12 +19,13 @@ var BaseRecordBatchCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
- {Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
+ {Name: "json", Desc: `batch create JSON object; create_records contains one field map per record, e.g. {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`, Required: true},
},
Tips: append([]string{
- "Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
+ "Happy path field: create_records is an array of independent record field maps.",
+ `Example: {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}.`,
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
- "Batch create supports max 200 rows per call.",
+ "Batch create supports max 200 records per call.",
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
"Use the record-batch-create guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
diff --git a/shortcuts/base/record_batch_update.go b/shortcuts/base/record_batch_update.go
index 74a52cea8..0fc23a179 100644
--- a/shortcuts/base/record_batch_update.go
+++ b/shortcuts/base/record_batch_update.go
@@ -12,18 +12,19 @@ import (
var BaseRecordBatchUpdate = common.Shortcut{
Service: "base",
Command: "+record-batch-update",
- Description: "Batch update records",
+ Description: "Batch update records with record-specific fields",
Risk: "write",
Scopes: []string{"base:record:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
- {Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true},
+ {Name: "json", Desc: `batch update JSON object; update_records maps each record ID to its field map, e.g. {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`, Required: true},
},
Tips: append([]string{
- "Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.",
- "Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.",
+ "Happy path field: update_records maps each record ID to its own field map.",
+ `Example: {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}.`,
+ "The response contains only optional ignored_fields and does not check whether record IDs exist; read records back when confirmation is required.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
diff --git a/shortcuts/base/record_get.go b/shortcuts/base/record_get.go
index f8d0b720a..6ba1eda59 100644
--- a/shortcuts/base/record_get.go
+++ b/shortcuts/base/record_get.go
@@ -21,7 +21,9 @@ var BaseRecordGet = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
- {Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
+ recordProjectionFieldFlag("field ID or name to project; repeat to keep only needed columns"),
+ recordProjectionAliasFlag("fields"),
+ recordProjectionAliasFlag("field-names"),
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
recordReadFormatFlag(),
},
diff --git a/shortcuts/base/record_list.go b/shortcuts/base/record_list.go
index 59e129add..d8d64cd9e 100644
--- a/shortcuts/base/record_list.go
+++ b/shortcuts/base/record_list.go
@@ -20,8 +20,9 @@ var BaseRecordList = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
- recordListFieldRefFlag(),
- recordListFieldNamesAliasFlag(),
+ recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
+ recordProjectionAliasFlag("fields"),
+ recordProjectionAliasFlag("field-names"),
recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),
@@ -44,9 +45,6 @@ var BaseRecordList = common.Shortcut{
"Use --field-id repeatedly to keep output small and aligned with the task.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
- if err := validateRecordListFieldAlias(runtime); err != nil {
- return err
- }
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
@@ -61,6 +59,9 @@ var BaseRecordList = common.Shortcut{
return err
}
}
+ if _, err := recordProjectionFields(runtime); err != nil {
+ return err
+ }
return validateRecordQueryOptions(runtime)
},
DryRun: dryRunRecordList,
@@ -72,22 +73,6 @@ var BaseRecordList = common.Shortcut{
},
}
-func recordListFieldRefFlag() common.Flag {
- flag := fieldRefFlag(false)
- flag.Type = "string_array"
- flag.Desc = "field ID or name to include; repeat to project only needed fields"
- return flag
-}
-
-func recordListFieldNamesAliasFlag() common.Flag {
- return common.Flag{
- Name: "field-names",
- Type: "string_slice",
- Desc: "hidden alias for --field-id; accepts comma-separated field names",
- Hidden: true,
- }
-}
-
func recordListViewRefFlag() common.Flag {
flag := viewRefFlag(false)
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
@@ -102,10 +87,3 @@ func recordReadFormatFlag() common.Flag {
Desc: "output format: markdown (default) | json",
}
}
-
-func validateRecordListFieldAlias(runtime *common.RuntimeContext) error {
- if runtime.Changed("field-id") && runtime.Changed("field-names") {
- return baseFlagErrorf("--field-id and --field-names are mutually exclusive; use --field-id")
- }
- return nil
-}
diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go
index 1549602d8..3b809a834 100644
--- a/shortcuts/base/record_ops.go
+++ b/shortcuts/base/record_ops.go
@@ -5,18 +5,21 @@ package base
import (
"context"
+ "errors"
"net/url"
"strconv"
"strings"
+ "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const maxRecordSelectionCount = 200
const maxBatchGetSelectFieldCount = 100
+const maxRecordSearchSelectFieldCount = 50
var recordCellValueHappyPathTips = []string{
- `CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
+ `CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select (multiple=false) -> "Todo"; select (multiple=true) -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",
@@ -46,7 +49,6 @@ func validateRecordSelection(runtime *common.RuntimeContext) error {
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
recordIDs := runtime.StrArray("record-id")
- fieldIDs := runtime.StrArray("field-id")
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if len(recordIDs) > 0 && jsonRaw != "" {
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
@@ -69,7 +71,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
if err != nil {
return recordSelection{}, err
}
- selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
+ projectionFields, err := recordProjectionFields(runtime)
+ if err != nil {
+ return recordSelection{}, err
+ }
+ selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), body)
if err != nil {
return recordSelection{}, err
}
@@ -83,7 +89,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
if err != nil {
return recordSelection{}, err
}
- selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
+ projectionFields, err := recordProjectionFields(runtime)
+ if err != nil {
+ return recordSelection{}, err
+ }
+ selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), nil)
if err != nil {
return recordSelection{}, err
}
@@ -104,20 +114,20 @@ func normalizeRecordIDs(values interface{}) ([]string, error) {
})
}
-func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
+func resolveRecordGetSelectFields(flagFields []string, projectionParam string, body map[string]interface{}) ([]string, error) {
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
if err != nil {
- return nil, err
+ return nil, withValidationParam(err, projectionParam)
}
if body == nil {
return fromFlags, nil
}
rawJSONFields, ok := body["select_fields"]
- if !ok {
+ if !ok || rawJSONFields == nil {
return fromFlags, nil
}
if len(fromFlags) > 0 {
- return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
+ return nil, baseFlagErrorf(`%s and --json field "select_fields" are mutually exclusive`, projectionParam)
}
items, ok := rawJSONFields.([]interface{})
if !ok {
@@ -128,18 +138,26 @@ func resolveRecordGetSelectFields(flagFields []string, body map[string]interface
}
normalized, err := normalizeRecordGetSelectFields(items)
if err != nil {
- return nil, err
+ return nil, withValidationParam(err, "--json")
}
return normalized, nil
}
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
+ return normalizeRecordSelectFields(values, maxBatchGetSelectFieldCount)
+}
+
+func normalizeRecordSearchSelectFields(values interface{}) ([]string, error) {
+ return normalizeRecordSelectFields(values, maxRecordSearchSelectFieldCount)
+}
+
+func normalizeRecordSelectFields(values interface{}, max int) ([]string, error) {
return normalizeStringList(values, stringListNormalizeOptions{
typeError: "field selection must be a string array",
itemName: "field selection item",
duplicateName: "field id",
limitName: "field selection",
- max: maxBatchGetSelectFieldCount,
+ max: max,
allowNil: true,
allowEmpty: true,
})
@@ -211,7 +229,11 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
params := url.Values{}
params.Set("offset", strconv.Itoa(offset))
params.Set("limit", strconv.Itoa(limit))
- for _, field := range recordListFields(runtime) {
+ fields, err := recordProjectionFields(runtime)
+ if err != nil {
+ return common.NewDryRunAPI()
+ }
+ for _, field := range fields {
params.Add("field_id", field)
}
if viewID := runtime.Str("view-id"); viewID != "" {
@@ -375,11 +397,121 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
return err
}
-func recordListFields(runtime *common.RuntimeContext) []string {
- if runtime.Changed("field-names") {
- return runtime.StrSlice("field-names")
+func recordProjectionFieldFlag(desc string) common.Flag {
+ flag := fieldRefFlag(false)
+ flag.Type = "string_array"
+ flag.Desc = desc
+ return flag
+}
+
+func recordProjectionAliasFlag(name string) common.Flag {
+ flagType := "string_array"
+ if name == "field-names" {
+ // Preserve the original compatibility contract: --field-names uses
+ // pflag's CSV parser, including quoted commas, and treats @ literally.
+ flagType = "string_slice"
}
- return runtime.StrArray("field-id")
+ return common.Flag{
+ Name: name,
+ Type: flagType,
+ Desc: "hidden alias for --field-id projection",
+ Hidden: true,
+ }
+}
+
+func recordProjectionParam(runtime *common.RuntimeContext) string {
+ switch {
+ case runtime.Changed("fields"):
+ return "--fields"
+ case runtime.Changed("field-names"):
+ return "--field-names"
+ default:
+ return "--field-id"
+ }
+}
+
+func withValidationParam(err error, param string) error {
+ if err == nil || param == "" {
+ return err
+ }
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) {
+ return err
+ }
+ reason := validationErr.Error()
+ // The caller knows which input produced this validation error. Replace any
+ // params inferred from the rendered message: field values such as Cost--USD
+ // must not be mistaken for a --USD flag.
+ validationErr.Param = param
+ validationErr.Params = []errs.InvalidParam{{Name: param, Reason: reason}}
+ return err
+}
+
+func recordProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
+ return recordProjectionFieldsWithLimit(runtime, maxBatchGetSelectFieldCount)
+}
+
+func recordSearchProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
+ return recordProjectionFieldsWithLimit(runtime, maxRecordSearchSelectFieldCount)
+}
+
+func recordProjectionFieldsWithLimit(runtime *common.RuntimeContext, max int) ([]string, error) {
+ fieldIDs := runtime.StrArray("field-id")
+ fieldIDsSet := runtime.Changed("field-id")
+ fieldsSet := runtime.Changed("fields")
+ fieldNamesSet := runtime.Changed("field-names")
+ projectionParams := make([]string, 0, 3)
+ if fieldIDsSet {
+ projectionParams = append(projectionParams, "--field-id")
+ }
+ if fieldsSet {
+ projectionParams = append(projectionParams, "--fields")
+ }
+ if fieldNamesSet {
+ projectionParams = append(projectionParams, "--field-names")
+ }
+ if len(projectionParams) > 1 {
+ invalidParams := make([]errs.InvalidParam, 0, len(projectionParams))
+ for _, param := range projectionParams {
+ invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
+ }
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s are mutually exclusive", strings.Join(projectionParams, " and ")).
+ WithParam(projectionParams[0]).
+ WithParams(invalidParams...).
+ WithHint("Use only --field-id for projection.")
+ }
+ if fieldsSet {
+ return recordProjectionAliasFields(runtime, "fields", max)
+ }
+ if fieldNamesSet {
+ return recordProjectionAliasFields(runtime, "field-names", max)
+ }
+ fields, err := normalizeRecordSelectFields(fieldIDs, max)
+ return fields, withValidationParam(err, "--field-id")
+}
+
+func recordProjectionAliasFields(runtime *common.RuntimeContext, flagName string, max int) ([]string, error) {
+ var fields []string
+ if flagName == "field-names" {
+ fields = runtime.StrSlice(flagName)
+ } else {
+ pc := newParseCtx(runtime)
+ values := runtime.StrArray(flagName)
+ fields = make([]string, 0, len(values))
+ for _, raw := range values {
+ parsed, err := parseStringListFlexible(pc, raw, flagName)
+ if err != nil {
+ return nil, withValidationParam(err, "--"+flagName)
+ }
+ fields = append(fields, parsed...)
+ }
+ }
+ if len(fields) == 0 {
+ err := baseFlagErrorf("--%s must include at least one field", flagName)
+ return nil, withValidationParam(err, "--"+flagName)
+ }
+ normalized, err := normalizeRecordSelectFields(fields, max)
+ return normalized, withValidationParam(err, "--"+flagName)
}
func executeRecordList(runtime *common.RuntimeContext) error {
@@ -392,7 +524,10 @@ func executeRecordList(runtime *common.RuntimeContext) error {
}
limit := getPaginationLimit(runtime)
params := map[string]interface{}{"offset": offset, "limit": limit}
- fields := recordListFields(runtime)
+ fields, err := recordProjectionFields(runtime)
+ if err != nil {
+ return err
+ }
if len(fields) > 0 {
params["field_id"] = fields
}
diff --git a/shortcuts/base/record_query.go b/shortcuts/base/record_query.go
index efab7f02c..e74de30df 100644
--- a/shortcuts/base/record_query.go
+++ b/shortcuts/base/record_query.go
@@ -9,6 +9,7 @@ import (
"net/url"
"strings"
+ "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -174,7 +175,10 @@ func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{
if len(searchFields) > 0 {
body["search_fields"] = searchFields
}
- selectFields := recordListFields(runtime)
+ selectFields, err := recordSearchProjectionFields(runtime)
+ if err != nil {
+ return nil, err
+ }
if len(selectFields) > 0 {
body["select_fields"] = selectFields
}
@@ -203,6 +207,19 @@ func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{
}
func normalizeRecordSearchJSONBody(body map[string]interface{}) error {
+ if rawSelectFields, ok := body["select_fields"]; ok {
+ if rawSelectFields == nil {
+ delete(body, "select_fields")
+ } else {
+ selectFields, err := normalizeRecordSearchSelectFields(rawSelectFields)
+ if err != nil {
+ return withValidationParam(err, "--json")
+ }
+ if len(selectFields) > 0 {
+ body["select_fields"] = selectFields
+ }
+ }
+ }
if rawSort, ok := body["sort"]; ok {
if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil {
body["sort"] = sortConfig
@@ -219,8 +236,20 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
}
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if jsonRaw != "" {
- if recordSearchHasJSONExclusiveFlagInputs(runtime) {
- return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
+ if exclusiveParams := recordSearchJSONExclusiveFlagParams(runtime); len(exclusiveParams) > 0 {
+ allParams := append([]string{"--json"}, exclusiveParams...)
+ invalidParams := make([]errs.InvalidParam, 0, len(allParams))
+ for _, param := range allParams {
+ invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
+ }
+ return errs.NewValidationError(
+ errs.SubtypeInvalidArgument,
+ "--json is mutually exclusive with %s",
+ strings.Join(exclusiveParams, " and "),
+ ).
+ WithParam("--json").
+ WithParams(invalidParams...).
+ WithHint("Put keyword, search, projection, view, and pagination fields inside --json, or omit --json.")
}
_, err := recordSearchJSONBody(runtime)
return err
@@ -242,17 +271,31 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
return err
}
}
+ if _, err := recordSearchProjectionFields(runtime); err != nil {
+ return err
+ }
return validateRecordQueryOptions(runtime)
}
-func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
- return strings.TrimSpace(runtime.Str("keyword")) != "" ||
- len(runtime.StrArray("search-field")) > 0 ||
- len(recordListFields(runtime)) > 0 ||
- runtime.Str("view-id") != "" ||
- runtime.Changed("offset") ||
- runtime.Changed("limit") ||
- runtime.Changed("page-size")
+func recordSearchJSONExclusiveFlagParams(runtime *common.RuntimeContext) []string {
+ names := []string{
+ "keyword",
+ "search-field",
+ "field-id",
+ "fields",
+ "field-names",
+ "view-id",
+ "offset",
+ "limit",
+ "page-size",
+ }
+ params := make([]string, 0, len(names))
+ for _, name := range names {
+ if runtime.Changed(name) {
+ params = append(params, "--"+name)
+ }
+ }
+ return params
}
func formatRecordQueryPriorityTip() string {
diff --git a/shortcuts/base/record_search.go b/shortcuts/base/record_search.go
index 5bf6796bd..c4100f760 100644
--- a/shortcuts/base/record_search.go
+++ b/shortcuts/base/record_search.go
@@ -23,7 +23,9 @@ var BaseRecordSearch = common.Shortcut{
{Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`},
{Name: "keyword", Desc: "keyword for record search; required unless --json is used"},
{Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"},
- recordListFieldRefFlag(),
+ recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
+ recordProjectionAliasFlag("fields"),
+ recordProjectionAliasFlag("field-names"),
recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),
diff --git a/shortcuts/base/record_upsert.go b/shortcuts/base/record_upsert.go
index abcea7e9f..12a26f7fd 100644
--- a/shortcuts/base/record_upsert.go
+++ b/shortcuts/base/record_upsert.go
@@ -26,6 +26,7 @@ var BaseRecordUpsert = common.Shortcut{
"Happy path JSON is a top-level field map: each key is a real field name or field ID, each value is that field's CellValue.",
"Without --record-id this creates a record; with --record-id this updates that record. It does not auto-upsert by business key.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
+ "Sub-record/child-record path: when a one-way/two-way link field represents hierarchy, create a normal record and set that link field to a parent record reference array, e.g. {\"Parent Link\":[{\"id\":\"rec_xxx\"}]}; do not look for parent_record_id or a separate child-record API.",
"Use the record-upsert guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
diff --git a/shortcuts/calendar/calendar_agenda.go b/shortcuts/calendar/calendar_agenda.go
index 225727d02..0df6b8d86 100644
--- a/shortcuts/calendar/calendar_agenda.go
+++ b/shortcuts/calendar/calendar_agenda.go
@@ -250,6 +250,8 @@ var CalendarAgenda = common.Shortcut{
}
}
+ collapseDescription(e)
+
filtered = append(filtered, e)
}
}
diff --git a/shortcuts/calendar/calendar_create.go b/shortcuts/calendar/calendar_create.go
index effb5f677..361ebf92a 100644
--- a/shortcuts/calendar/calendar_create.go
+++ b/shortcuts/calendar/calendar_create.go
@@ -20,7 +20,6 @@ import (
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
eventData := map[string]interface{}{
"summary": runtime.Str("summary"),
- "description": runtime.Str("description"),
"start_time": map[string]string{"timestamp": startTs},
"end_time": map[string]string{"timestamp": endTs},
"attendee_ability": "can_modify_event",
@@ -33,6 +32,9 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
if rrule := runtime.Str("rrule"); rrule != "" {
eventData["recurrence"] = rrule
}
+ if description := descriptionToSend(runtime); description != "" {
+ eventData["description_rich"] = description
+ }
return eventData
}
@@ -67,6 +69,25 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
return attendees, nil
}
+// selfAttendeeId resolves the open_id of the identity running the command so it
+// can be auto-added to the attendee list, mirroring how a human user is joined
+// to their own events. For a user it comes from config; for a bot it is fetched
+// from /bot/v3/info. If the bot lookup fails, we warn and return "" so the event
+// is still created with the explicitly requested attendees.
+func selfAttendeeId(runtime *common.RuntimeContext) string {
+ if !runtime.IsBot() {
+ return runtime.UserOpenId()
+ }
+ info, err := runtime.BotInfo()
+ if err != nil {
+ fmt.Fprintf(runtime.IO().ErrOut,
+ "[calendar +create] warning: could not resolve bot identity to add it as an attendee (%v); proceeding without the bot\n",
+ err)
+ return ""
+ }
+ return info.OpenID
+}
+
func attendeesIncludeRoom(attendees []map[string]string) bool {
for _, attendee := range attendees {
if attendee["type"] == "resource" || attendee["room_id"] != "" {
@@ -99,7 +120,7 @@ var CalendarCreate = common.Shortcut{
{Name: "summary", Desc: "event title"},
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
- {Name: "description", Desc: "event description"},
+ {Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `
`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a
2. b`, `- x
- y`, `
**bold**`).", Input: []string{common.File, common.Stdin}},
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -176,7 +197,9 @@ var CalendarCreate = common.Shortcut{
eventData := buildEventData(runtime, startTs, endTs)
attendeesStr := runtime.Str("attendee-ids")
if attendeesStr != "" {
- // Note: dry-run doesn't network resolve the current user's open_id.
+ // Note: dry-run doesn't network resolve the running identity's own
+ // open_id (user from config, bot from /bot/v3/info), so the auto-joined
+ // self attendee is not shown here.
attendees, err := parseAttendees(attendeesStr, "")
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
@@ -210,6 +233,9 @@ var CalendarCreate = common.Shortcut{
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
+ if err := resolveDescriptionImages(runtime, calendarId); err != nil {
+ return err
+ }
eventData := buildEventData(runtime, startTs, endTs)
@@ -228,11 +254,8 @@ var CalendarCreate = common.Shortcut{
// Add attendees if specified
if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" {
- currentUserId := ""
- if !runtime.IsBot() {
- currentUserId = runtime.UserOpenId()
- }
- attendees, err := parseAttendees(attendeesStr, currentUserId)
+ selfId := selfAttendeeId(runtime)
+ attendees, err := parseAttendees(attendeesStr, selfId)
if err != nil {
return withParam(err, "--attendee-ids")
}
diff --git a/shortcuts/calendar/calendar_get.go b/shortcuts/calendar/calendar_get.go
index a49fb1fbb..1b4593e3d 100644
--- a/shortcuts/calendar/calendar_get.go
+++ b/shortcuts/calendar/calendar_get.go
@@ -81,6 +81,7 @@ type calendarEvent struct {
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
+ DescriptionRich string `json:"description_rich,omitempty"`
StartTime *calendarEventTime `json:"start_time,omitempty"`
EndTime *calendarEventTime `json:"end_time,omitempty"`
VChat *calendarEventVChat `json:"vchat,omitempty"`
@@ -169,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
if status, _ := out["status"].(string); status != "cancelled" {
delete(out, "status")
}
-
+ collapseDescription(out)
return out, nil
}
diff --git a/shortcuts/calendar/calendar_room_check.go b/shortcuts/calendar/calendar_room_check.go
new file mode 100644
index 000000000..7de7e148e
--- /dev/null
+++ b/shortcuts/calendar/calendar_room_check.go
@@ -0,0 +1,790 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+//
+// calendar +update room-availability pre-check helpers.
+//
+// Uses /open-apis/calendar/v4/freebusy/room_availability_check to warn the
+// caller before an update either adds a new room attendee or shifts the time
+// of a slot that already has a room reservation. --skip-room-check bypasses
+// the check for callers that want to move fast.
+
+package calendar
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+const (
+ flagSkipRoomCheck = "skip-room-check"
+ roomCheckPath = "/open-apis/calendar/v4/freebusy/room_availability_check"
+)
+
+// roomAvailability mirrors a single room result from the API.
+type roomAvailability struct {
+ RoomID string `json:"room_id,omitempty"`
+ RoomName string `json:"room_name,omitempty"`
+ Status string `json:"status,omitempty"`
+ UnavailableReasonType string `json:"unavailable_reason_type,omitempty"`
+ Strategy *roomStrategy `json:"room_strategy,omitempty"`
+ Requisition *roomRequisition `json:"room_requisition,omitempty"`
+ ApprovalInfo *roomApprovalInfo `json:"room_approval_info,omitempty"`
+}
+
+// roomStrategy mirrors the room_strategy block returned by the API on
+// unavailable rooms. Every field is optional: the server only fills in the
+// entries relevant to the current unavailable_reason_type.
+type roomStrategy struct {
+ SingleMaxDuration string `json:"single_max_duration,omitempty"`
+ MaxAdvanceBookingTime string `json:"max_advance_booking_time,omitempty"`
+ DailyStartTime string `json:"daily_start_time,omitempty"`
+ DailyEndTime string `json:"daily_end_time,omitempty"`
+ Timezone string `json:"timezone,omitempty"`
+ DailyAdvanceWindowReleaseTime string `json:"daily_advance_window_release_time,omitempty"`
+}
+
+// roomRequisition mirrors room_requisition, returned by the API only when
+// unavailable_reason_type == "during_requisition". Both fields are RFC3339
+// strings and either may be empty if the server has no exact bound.
+type roomRequisition struct {
+ StartTime string `json:"start_time,omitempty"`
+ EndTime string `json:"end_time,omitempty"`
+}
+
+// roomApprovalInfo mirrors room_approval_info, returned when the room requires
+// (or may require) an approval submission before it can be booked.
+//
+// - ApprovalMode: "none" (no approval), "over_duration" (only when the
+// booking exceeds the threshold), or "all" (every booking needs approval).
+// - ApprovalDurationThreshold: seconds; only meaningful when
+// ApprovalMode == "over_duration". The server returns it as a numeric
+// string, matching the shape of the other duration fields.
+//
+// When the pre-check returns status == "need_approval" the caller renders a
+// friendly reminder derived from these two fields plus the current event
+// duration, so the agent knows whether to switch rooms/times or route the
+// user through an approval flow.
+type roomApprovalInfo struct {
+ ApprovalMode string `json:"approval_mode,omitempty"`
+ ApprovalDurationThreshold string `json:"approval_duration_threshold,omitempty"`
+}
+
+// eventSnapshot carries only the fields room-check needs from the current
+// event: existing room IDs, current start/end (unix seconds string), timezone,
+// and rrule.
+type eventSnapshot struct {
+ RoomIDs []string
+ StartTs string
+ EndTs string
+ Timezone string
+ Recurrent string
+}
+
+// unavailableReasonHint maps API-declared unavailable reasons to a short
+// English phrase suitable for embedding in the block message. Unknown or
+// future reasons fall back to a single stable phrase so the CLI's blocked
+// message stays predictable for agents that parse it.
+func unavailableReasonHint(reason string) string {
+ switch reason {
+ case "reserved_by_other_event":
+ return "already reserved by another event"
+ case "past_time":
+ return "cannot book a room in the past"
+ case "beyond_advance_booking_window":
+ return "beyond the room's advance-booking window"
+ case "over_max_duration":
+ return "exceeds the room's max single-booking duration"
+ case "not_in_usable_time":
+ return "outside the room's daily bookable window"
+ case "during_requisition":
+ return "the room is disabled during this time and cannot be booked"
+ case "before_daily_advance_window_release":
+ return "the target date is outside the room's currently unlocked advance-booking window; the window extends by one calendar day at the daily release time"
+ case "recurring_exceed_approval_limit":
+ return "recurring event duration exceeds the limit for booking this approval-required room — shorten the duration or pick a different room"
+ default:
+ return "currently unbookable"
+ }
+}
+
+// strategyDetail renders the human-readable suffix appended to the reason
+// phrase for a given (reason, strategy) pair. It returns an empty string when
+// no strategy data is available or when the fields relevant to this reason
+// are missing / invalid, so callers can safely concatenate the result.
+func strategyDetail(reason string, s *roomStrategy) string {
+ if s == nil {
+ return ""
+ }
+ switch reason {
+ case "over_max_duration":
+ if d := formatDurationSeconds(s.SingleMaxDuration); d != "" {
+ return "the max single-booking duration is " + d
+ }
+ case "beyond_advance_booking_window":
+ // The API returns max_advance_booking_time as RFC3339 already;
+ // surface it verbatim so agents don't lose the exact instant.
+ if t := strings.TrimSpace(s.MaxAdvanceBookingTime); t != "" {
+ return "the latest bookable end time is " + t
+ }
+ case "not_in_usable_time":
+ start := formatDaySeconds(s.DailyStartTime)
+ end := formatDaySeconds(s.DailyEndTime)
+ zone := roomZoneLabel(s.Timezone)
+ switch {
+ case start != "" && end != "":
+ return fmt.Sprintf("the daily bookable window is %s - %s (%s)", start, end, zone)
+ case start != "":
+ return fmt.Sprintf("the daily bookable window starts at %s (%s)", start, zone)
+ case end != "":
+ return fmt.Sprintf("the daily bookable window ends at %s (%s)", end, zone)
+ }
+ case "before_daily_advance_window_release":
+ if t := formatDaySeconds(s.DailyAdvanceWindowReleaseTime); t != "" {
+ return fmt.Sprintf("the next unlock happens today at %s (%s), which advances the window by one day", t, roomZoneLabel(s.Timezone))
+ }
+ }
+ return ""
+}
+
+// requisitionDetail renders the suffix describing the room's scheduled
+// disable window for a `during_requisition` block. The API sends both bounds
+// as RFC3339 already, so we surface them verbatim to keep the exact instant.
+// Returns "" when both bounds are missing so the caller falls back to the
+// generic "pick a different time or a different room" recovery hint.
+func requisitionDetail(reason string, r *roomRequisition) string {
+ if reason != "during_requisition" || r == nil {
+ return ""
+ }
+ start := strings.TrimSpace(r.StartTime)
+ end := strings.TrimSpace(r.EndTime)
+ switch {
+ case start != "" && end != "":
+ return fmt.Sprintf("the disabled period is %s to %s", start, end)
+ case start != "":
+ return "the disabled period starts at " + start
+ case end != "":
+ return "the disabled period ends at " + end
+ }
+ return ""
+}
+
+// formatDurationSeconds renders a whole-second string like "10800" as a
+// compact "H hours [M minutes]" phrase. Returns "" when the value is
+// missing, non-numeric, or non-positive.
+func formatDurationSeconds(raw string) string {
+ sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
+ if err != nil || sec <= 0 {
+ return ""
+ }
+ d := time.Duration(sec) * time.Second
+ h := int(d / time.Hour)
+ m := int((d % time.Hour) / time.Minute)
+ switch {
+ case h > 0 && m > 0:
+ return fmt.Sprintf("%d hours %d minutes", h, m)
+ case h > 0:
+ return fmt.Sprintf("%d hours", h)
+ case m > 0:
+ return fmt.Sprintf("%d minutes", m)
+ default:
+ return fmt.Sprintf("%d seconds", sec)
+ }
+}
+
+// formatDaySeconds renders a "seconds since midnight" string as "HH:MM".
+// Returns "" when raw is missing, non-numeric, or outside [0, 24h). Seconds
+// are truncated because the API only guarantees minute-level meaning for
+// daily windows and release times.
+func formatDaySeconds(raw string) string {
+ sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
+ if err != nil || sec < 0 || sec >= 24*3600 {
+ return ""
+ }
+ h := sec / 3600
+ m := (sec % 3600) / 60
+ return fmt.Sprintf("%02d:%02d", h, m)
+}
+
+// roomZoneLabel renders the room's timezone as either a "GMT±X" string
+// anchored to today (so DST is respected) when the IANA name resolves, or
+// the IANA name itself as a fallback so agents always see the source of
+// truth. Returns the local device timezone's label when raw is empty.
+func roomZoneLabel(raw string) string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return gmtOffsetLabel(time.Now())
+ }
+ loc, err := time.LoadLocation(raw)
+ if err != nil {
+ return raw
+ }
+ return gmtOffsetLabel(time.Now().In(loc))
+}
+
+// gmtOffsetLabel formats t's zone offset as "GMT+8" / "GMT-5:30" / "GMT".
+// Minute-precision is included only when the offset has a non-zero minute
+// component so the common whole-hour case stays terse.
+func gmtOffsetLabel(t time.Time) string {
+ _, offsetSec := t.Zone()
+ if offsetSec == 0 {
+ return "GMT"
+ }
+ sign := "+"
+ if offsetSec < 0 {
+ sign = "-"
+ offsetSec = -offsetSec
+ }
+ h := offsetSec / 3600
+ m := (offsetSec % 3600) / 60
+ if m == 0 {
+ return fmt.Sprintf("GMT%s%d", sign, h)
+ }
+ return fmt.Sprintf("GMT%s%d:%02d", sign, h, m)
+}
+
+// collectAttendeeRoomIDs extracts omm_ prefixed IDs from a comma-separated
+// flag value. Empty / whitespace input returns nil.
+func collectAttendeeRoomIDs(raw string) []string {
+ if strings.TrimSpace(raw) == "" {
+ return nil
+ }
+ var rooms []string
+ seen := map[string]struct{}{}
+ for _, part := range strings.Split(raw, ",") {
+ id := strings.TrimSpace(part)
+ if !strings.HasPrefix(id, "omm_") {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ rooms = append(rooms, id)
+ }
+ return rooms
+}
+
+// fetchEventSnapshot GETs the event with attendees so we can read the current
+// start / end / recurrence and the room IDs already booked on the event. It is
+// best-effort: any error bubbles up so the caller can降级放行 by warning.
+//
+// One retry is baked in: a `{uid}_{original_time}` event_id refers to a
+// specific instance of a recurring series, but until that instance is edited
+// and materialised as an exception, the server only knows the master
+// (`{uid}_0`) and answers 193001 (event not found). We detect that shape and
+// re-issue the GET against the master so the room-check pipeline still has a
+// snapshot to work with.
+func fetchEventSnapshot(_ context.Context, runtime *common.RuntimeContext, calendarID, eventID string) (*eventSnapshot, error) {
+ data, err := callEventGet(runtime, calendarID, eventID)
+ if err != nil {
+ if masterID, ok := recurringMasterEventID(eventID); ok && isEventNotFound(err) {
+ data, err = callEventGet(runtime, calendarID, masterID)
+ }
+ if err != nil {
+ return nil, err
+ }
+ }
+ event, _ := data["event"].(map[string]interface{})
+ if event == nil {
+ return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
+ }
+ snap := &eventSnapshot{}
+ if start, _ := event["start_time"].(map[string]interface{}); start != nil {
+ if ts, _ := start["timestamp"].(string); ts != "" {
+ snap.StartTs = ts
+ }
+ if tz, _ := start["timezone"].(string); tz != "" {
+ snap.Timezone = tz
+ }
+ }
+ if end, _ := event["end_time"].(map[string]interface{}); end != nil {
+ if ts, _ := end["timestamp"].(string); ts != "" {
+ snap.EndTs = ts
+ }
+ if snap.Timezone == "" {
+ if tz, _ := end["timezone"].(string); tz != "" {
+ snap.Timezone = tz
+ }
+ }
+ }
+ if r, _ := event["recurrence"].(string); r != "" {
+ snap.Recurrent = r
+ }
+ attendees, _ := event["attendees"].([]interface{})
+ seen := map[string]struct{}{}
+ for _, raw := range attendees {
+ m, ok := raw.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ if t, _ := m["type"].(string); t != "resource" {
+ continue
+ }
+ id, _ := m["room_id"].(string)
+ if id == "" {
+ continue
+ }
+ if status, _ := m["rsvp_status"].(string); status == "removed" {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ snap.RoomIDs = append(snap.RoomIDs, id)
+ }
+ return snap, nil
+}
+
+// callEventGet issues the calendar event GET used by fetchEventSnapshot. It
+// is factored out so the 193001 fallback can re-issue the request against
+// the master event without duplicating the params / path plumbing.
+func callEventGet(runtime *common.RuntimeContext, calendarID, eventID string) (map[string]interface{}, error) {
+ path := fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
+ validate.EncodePathSegment(calendarID), validate.EncodePathSegment(eventID))
+ params := map[string]interface{}{
+ "user_id_type": "open_id",
+ "need_attendee": true,
+ "max_attendee_num": 20,
+ }
+ return runtime.CallAPITyped("GET", path, params, nil)
+}
+
+// recurringMasterEventID inspects a calendar event_id shaped like
+// `{uid}_{original_time}` and returns `{uid}_0` when original_time is a
+// positive integer, plus true so callers know a fallback is worth trying.
+// Any other shape (missing underscore, non-numeric suffix, already `_0`, or
+// suffix `0` / negative) returns "", false so we don't retry pointlessly.
+func recurringMasterEventID(eventID string) (string, bool) {
+ idx := strings.LastIndex(eventID, "_")
+ if idx <= 0 || idx == len(eventID)-1 {
+ return "", false
+ }
+ uid := eventID[:idx]
+ suffix := eventID[idx+1:]
+ n, err := strconv.ParseInt(suffix, 10, 64)
+ if err != nil || n <= 0 {
+ return "", false
+ }
+ return uid + "_0", true
+}
+
+// isEventNotFound returns true when err is a calendar 193001 (event not
+// found) API error. Kept in this file rather than shared with
+// unwrapCalendarAPIError because that helper returns a user-facing hint —
+// here we only need the classification, not the copy.
+func isEventNotFound(err error) bool {
+ if err == nil {
+ return false
+ }
+ var ae *errs.APIError
+ if !errors.As(err, &ae) {
+ return false
+ }
+ return ae.Code == 193001
+}
+
+// roomCheckPlan bundles the resolved inputs for the pre-check API call.
+type roomCheckPlan struct {
+ RoomIDs []string
+ StartTs string
+ EndTs string
+ StartTimezone string
+ Rrule string
+}
+
+// resolveRoomCheckPlan works out which rooms to check and the target time
+// window. It applies the降级放行 policy: if the event snapshot fails to load
+// but we can proceed with only user-provided inputs (i.e., time changed and a
+// new room is added), the pre-check still runs against those. Otherwise it
+// warns and returns (nil, nil) so the caller skips the check.
+//
+// Returns (nil, nil) when no check is warranted.
+func resolveRoomCheckPlan(ctx context.Context, runtime *common.RuntimeContext, calendarID, eventID string, newStartTs, newEndTs string, timeChanged, rruleChanged bool) (*roomCheckPlan, error) {
+ newRooms := collectAttendeeRoomIDs(runtime.Str("add-attendee-ids"))
+ removeSet := map[string]struct{}{}
+ for _, id := range collectAttendeeRoomIDs(runtime.Str("remove-attendee-ids")) {
+ removeSet[id] = struct{}{}
+ }
+
+ // Fast path: only trigger the check when it can find something to look at.
+ // - New room attendees → always check.
+ // - Time or rrule change → check existing rooms if any.
+ if len(newRooms) == 0 && !timeChanged && !rruleChanged {
+ return nil, nil
+ }
+
+ newRrule := strings.TrimSpace(runtime.Str("rrule"))
+
+ // If we don't need existing rooms and have both start/end, skip the GET.
+ needSnapshot := timeChanged || rruleChanged || !timeChanged && len(newRooms) > 0
+
+ var snap *eventSnapshot
+ if needSnapshot {
+ var err error
+ snap, err = fetchEventSnapshot(ctx, runtime, calendarID, eventID)
+ if err != nil {
+ fmt.Fprintf(runtime.IO().ErrOut,
+ "[calendar +update] warning: failed to fetch current event for room-availability check (%v); precheck runs only against user-supplied inputs — pass --%s to silence\n",
+ err, flagSkipRoomCheck)
+ snap = nil
+ }
+ }
+
+ plan := &roomCheckPlan{
+ StartTs: newStartTs,
+ EndTs: newEndTs,
+ Rrule: newRrule,
+ }
+ if plan.StartTs == "" && snap != nil {
+ plan.StartTs = snap.StartTs
+ }
+ if plan.EndTs == "" && snap != nil {
+ plan.EndTs = snap.EndTs
+ }
+ if plan.Rrule == "" && snap != nil {
+ plan.Rrule = snap.Recurrent
+ }
+ if snap != nil {
+ plan.StartTimezone = snap.Timezone
+ }
+
+ seen := map[string]struct{}{}
+ addRoom := func(id string) {
+ if id == "" {
+ return
+ }
+ if _, ok := removeSet[id]; ok {
+ return
+ }
+ if _, ok := seen[id]; ok {
+ return
+ }
+ seen[id] = struct{}{}
+ plan.RoomIDs = append(plan.RoomIDs, id)
+ }
+ for _, id := range newRooms {
+ addRoom(id)
+ }
+ if snap != nil && (timeChanged || rruleChanged) {
+ for _, id := range snap.RoomIDs {
+ addRoom(id)
+ }
+ }
+
+ if len(plan.RoomIDs) == 0 {
+ return nil, nil
+ }
+ // Without a target window the server has no basis to check anything;
+ // prefer degrading gracefully to blocking legitimate updates.
+ if plan.StartTs == "" || plan.EndTs == "" {
+ fmt.Fprintf(runtime.IO().ErrOut,
+ "[calendar +update] warning: room-availability check skipped because start/end could not be resolved; pass --%s to silence\n",
+ flagSkipRoomCheck)
+ return nil, nil
+ }
+ return plan, nil
+}
+
+// roomCheckPlanDurationSec returns the current booking duration in whole
+// seconds derived from the resolved plan's Unix-second window, or 0 when
+// either bound is missing or unparseable. Used to compare against
+// approval_duration_threshold when the API asks for approval.
+func roomCheckPlanDurationSec(plan *roomCheckPlan) int64 {
+ if plan == nil {
+ return 0
+ }
+ start, err := strconv.ParseInt(strings.TrimSpace(plan.StartTs), 10, 64)
+ if err != nil {
+ return 0
+ }
+ end, err := strconv.ParseInt(strings.TrimSpace(plan.EndTs), 10, 64)
+ if err != nil {
+ return 0
+ }
+ if end <= start {
+ return 0
+ }
+ return end - start
+}
+
+// buildRoomCheckBody assembles the request body for room_availability_check.
+// The pre-check API expects start/end as RFC3339 timestamps; we take the
+// Unix-second strings used elsewhere in the update flow and render them in
+// the event's own timezone when available, falling back to the local device
+// timezone so agents on different machines still produce a valid request.
+// start_timezone is an IANA name (e.g. "Asia/Shanghai") copied from the event
+// snapshot; it is omitted when unknown so the server can fall back to its own
+// default.
+func buildRoomCheckBody(calendarID, eventID string, plan *roomCheckPlan) map[string]interface{} {
+ loc := time.Local
+ if plan.StartTimezone != "" {
+ if l, err := time.LoadLocation(plan.StartTimezone); err == nil {
+ loc = l
+ }
+ }
+ body := map[string]interface{}{
+ "calendar_id": calendarID,
+ "event_id": eventID,
+ "start_time": formatRoomCheckTime(plan.StartTs, loc),
+ "end_time": formatRoomCheckTime(plan.EndTs, loc),
+ "room_ids": plan.RoomIDs,
+ }
+ if plan.StartTimezone != "" {
+ body["start_timezone"] = plan.StartTimezone
+ }
+ if plan.Rrule != "" {
+ body["event_rrule"] = plan.Rrule
+ }
+ return body
+}
+
+// formatRoomCheckTime renders a Unix-second string as RFC3339 in loc.
+// Non-numeric input is returned unchanged so anomalies stay visible instead
+// of being silently rewritten to the epoch.
+func formatRoomCheckTime(unixStr string, loc *time.Location) string {
+ sec, err := strconv.ParseInt(strings.TrimSpace(unixStr), 10, 64)
+ if err != nil {
+ return unixStr
+ }
+ return time.Unix(sec, 0).In(loc).Format(time.RFC3339)
+}
+
+// callRoomAvailabilityCheck posts the availability request and returns per-room
+// results.
+func callRoomAvailabilityCheck(runtime *common.RuntimeContext, body map[string]interface{}) ([]roomAvailability, error) {
+ data, err := runtime.CallAPITyped("POST", roomCheckPath, nil, body)
+ if err != nil {
+ return nil, err
+ }
+ rawList, _ := data["room_availabilitys"].([]interface{})
+ out := make([]roomAvailability, 0, len(rawList))
+ for _, raw := range rawList {
+ m, ok := raw.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ item := roomAvailability{}
+ if v, ok := m["room_id"].(string); ok {
+ item.RoomID = v
+ }
+ if v, ok := m["room_name"].(string); ok {
+ item.RoomName = v
+ }
+ if v, ok := m["status"].(string); ok {
+ item.Status = v
+ }
+ if v, ok := m["unavailable_reason_type"].(string); ok {
+ item.UnavailableReasonType = v
+ }
+ if strat, ok := m["room_strategy"].(map[string]interface{}); ok {
+ item.Strategy = parseRoomStrategy(strat)
+ }
+ if req, ok := m["room_requisition"].(map[string]interface{}); ok {
+ item.Requisition = parseRoomRequisition(req)
+ }
+ if info, ok := m["room_approval_info"].(map[string]interface{}); ok {
+ item.ApprovalInfo = parseRoomApprovalInfo(info)
+ }
+ out = append(out, item)
+ }
+ return out, nil
+}
+
+// parseRoomStrategy extracts the optional strategy fields from a raw API
+// map. Missing / non-string values are dropped so callers only see what the
+// server actually sent.
+func parseRoomStrategy(m map[string]interface{}) *roomStrategy {
+ s := &roomStrategy{}
+ if v, ok := m["single_max_duration"].(string); ok {
+ s.SingleMaxDuration = v
+ }
+ if v, ok := m["max_advance_booking_time"].(string); ok {
+ s.MaxAdvanceBookingTime = v
+ }
+ if v, ok := m["daily_start_time"].(string); ok {
+ s.DailyStartTime = v
+ }
+ if v, ok := m["daily_end_time"].(string); ok {
+ s.DailyEndTime = v
+ }
+ if v, ok := m["timezone"].(string); ok {
+ s.Timezone = v
+ }
+ if v, ok := m["daily_advance_window_release_time"].(string); ok {
+ s.DailyAdvanceWindowReleaseTime = v
+ }
+ return s
+}
+
+// parseRoomRequisition extracts the optional room_requisition block from a
+// raw API map. Missing / non-string values are dropped.
+func parseRoomRequisition(m map[string]interface{}) *roomRequisition {
+ r := &roomRequisition{}
+ if v, ok := m["start_time"].(string); ok {
+ r.StartTime = v
+ }
+ if v, ok := m["end_time"].(string); ok {
+ r.EndTime = v
+ }
+ return r
+}
+
+// parseRoomApprovalInfo extracts the optional room_approval_info block from a
+// raw API map. Missing / non-string values are dropped.
+func parseRoomApprovalInfo(m map[string]interface{}) *roomApprovalInfo {
+ info := &roomApprovalInfo{}
+ if v, ok := m["approval_mode"].(string); ok {
+ info.ApprovalMode = v
+ }
+ if v, ok := m["approval_duration_threshold"].(string); ok {
+ info.ApprovalDurationThreshold = v
+ }
+ return info
+}
+
+// approvalReasonHint composes the per-line phrase for a `need_approval`
+// status. The API returns `room_approval_info` with:
+//
+// - "all" → every reservation on this room must be approved.
+// - "over_duration" → only bookings longer than approval_duration_threshold
+// need approval. The current event duration (eventDurationSec) is compared
+// against the threshold so agents can see exactly why approval is being
+// asked for — and, when the current duration is below the threshold, the
+// message points at the "shorten it" recovery path.
+// - anything else → generic reminder so unknown modes still surface.
+//
+// This function only produces the per-room fragment. The shared recovery
+// clause (attendees-create, client fallback, shorten, pick another room) is
+// appended once by blockOnUnavailableRooms into `.WithHint(...)` so a message
+// with several approval-required rooms doesn't repeat the same recovery
+// paragraph on every line.
+func approvalReasonHint(info *roomApprovalInfo, eventDurationSec int64) string {
+ mode := ""
+ if info != nil {
+ mode = strings.TrimSpace(info.ApprovalMode)
+ }
+ switch mode {
+ case "all":
+ return "this room requires approval for every reservation"
+ case "over_duration":
+ threshold, _ := strconv.ParseInt(strings.TrimSpace(info.ApprovalDurationThreshold), 10, 64)
+ if threshold <= 0 {
+ // Server said approval-by-duration but didn't give a threshold —
+ // keep the mode label so agents don't lose the classification.
+ return "this room requires approval when the booking exceeds a duration threshold"
+ }
+ thresholdPhrase := formatDurationSeconds(info.ApprovalDurationThreshold)
+ if thresholdPhrase == "" {
+ thresholdPhrase = fmt.Sprintf("%d seconds", threshold)
+ }
+ base := fmt.Sprintf("this room requires approval when the booking exceeds %s", thresholdPhrase)
+ if eventDurationSec > 0 {
+ currentPhrase := formatDurationSeconds(strconv.FormatInt(eventDurationSec, 10))
+ if currentPhrase == "" {
+ currentPhrase = fmt.Sprintf("%d seconds", eventDurationSec)
+ }
+ if eventDurationSec >= threshold {
+ base += fmt.Sprintf(" (current duration is %s)", currentPhrase)
+ } else {
+ // Server flagged approval but our duration reads as below the
+ // threshold — surface both so the agent can reconcile rather
+ // than guess.
+ base += fmt.Sprintf(" (current duration reads as %s; server still flagged approval)", currentPhrase)
+ }
+ }
+ return base
+ default:
+ return "this room requires approval before it can be booked"
+ }
+}
+
+// roomLabel renders the room identifier for the block message. When the API
+// returns a human-readable name it becomes `[]`; a blank
+// name (or an entirely blank id, defensive) degrades to whichever is present
+// so agents can still address the room. The room_id is kept as the primary
+// identifier because callers act on it programmatically. Square brackets are
+// used (rather than parentheses) so a room name that itself contains
+// parentheses — e.g. "Room A (west wing)" — doesn't produce ambiguous nesting
+// like `omm_1(Room A (west wing))`.
+func roomLabel(id, name string) string {
+ id = strings.TrimSpace(id)
+ name = strings.TrimSpace(name)
+ switch {
+ case id != "" && name != "":
+ return fmt.Sprintf("%s[%s]", id, name)
+ case id != "":
+ return id
+ default:
+ return name
+ }
+}
+
+// blockOnUnavailableRooms returns a typed validation error when any room in
+// results is unavailable or requires approval, or nil when everything is
+// bookable. The error text carries per-room reasons plus the retry command
+// hint from the PRD. When the API returns a room_strategy for a blocked room,
+// the relevant limit (max duration, latest bookable time, daily window, or
+// daily release time) is appended after the reason so agents can relay it to
+// the user without making a follow-up request. For a `during_requisition`
+// block, the disabled period (from room_requisition) is appended if available;
+// a "pick a different time or a different room" recovery clause is always
+// appended so the message reads coherently whether or not exact bounds are
+// known.
+//
+// `need_approval` results are treated as blocking (the CLI cannot submit an
+// approval on the user's behalf, so silently PATCHing would surprise the
+// user). The line uses room_approval_info + eventDurationSec to explain the
+// mode ("all" / "over_duration"), the threshold, and — for over_duration —
+// how the current booking compares. The shared "how do I actually recover
+// from approval" clause is folded into the hint once (not per line), so
+// several approval-required rooms don't repeat the same paragraph.
+func blockOnUnavailableRooms(results []roomAvailability, eventDurationSec int64) error {
+ var blocked []roomAvailability
+ for _, r := range results {
+ if r.Status != "available" {
+ blocked = append(blocked, r)
+ }
+ }
+ if len(blocked) == 0 {
+ return nil
+ }
+ var lines []string
+ hasNeedApproval := false
+ for _, r := range blocked {
+ var reason string
+ switch r.Status {
+ case "need_approval":
+ hasNeedApproval = true
+ reason = approvalReasonHint(r.ApprovalInfo, eventDurationSec)
+ default:
+ reason = unavailableReasonHint(r.UnavailableReasonType)
+ }
+ line := fmt.Sprintf("%s: %s", roomLabel(r.RoomID, r.RoomName), reason)
+ if detail := strategyDetail(r.UnavailableReasonType, r.Strategy); detail != "" {
+ line += ", " + detail
+ }
+ if detail := requisitionDetail(r.UnavailableReasonType, r.Requisition); detail != "" {
+ line += ", " + detail
+ }
+ if r.UnavailableReasonType == "during_requisition" {
+ line += "; pick a different time or a different room"
+ }
+ lines = append(lines, line)
+ }
+ msg := "meeting room booking will fail after this event change:\n " + strings.Join(lines, "\n ")
+ hint := fmt.Sprintf("do NOT auto-retry: relay the room IDs and reasons above to the user and get explicit confirmation before re-running with --%s.",
+ flagSkipRoomCheck)
+ if hasNeedApproval {
+ hint += " Rooms flagged need_approval: the CLI cannot submit approvals; DO NOT auto-run any recovery — ask the user first, then pick one: (a) newly added room → after the user confirms and provides `approval_reason`, run `lark-cli calendar event.attendees create --as user`; (b) time/rrule change re-triggers approval on an existing room → ask the user to update through the client; (c) shorten the meeting below the threshold or pick a different room."
+ }
+ return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", msg).WithHint("%s", hint)
+}
diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go
index 5453ee682..392116a4c 100644
--- a/shortcuts/calendar/calendar_test.go
+++ b/shortcuts/calendar/calendar_test.go
@@ -251,6 +251,136 @@ func TestCreate_WithAttendees_Success(t *testing.T) {
}
}
+func TestCreate_WithAttendees_AsBot_AddsBotSelf(t *testing.T) {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/bot/v3/info",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "bot": map[string]interface{}{
+ "open_id": "ou_botself",
+ "app_name": "Test Bot",
+ },
+ },
+ })
+ 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_bot",
+ "summary": "Bot Sync",
+ "start_time": map[string]interface{}{
+ "timestamp": "1742515200",
+ },
+ "end_time": map[string]interface{}{
+ "timestamp": "1742518800",
+ },
+ },
+ },
+ },
+ })
+ attendeesStub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/events/evt_bot/attendees",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{},
+ },
+ }
+ reg.Register(attendeesStub)
+
+ err := mountAndRun(t, CalendarCreate, []string{
+ "+create",
+ "--summary", "Bot Sync",
+ "--start", "2025-03-21T00:00:00+08:00",
+ "--end", "2025-03-21T01:00:00+08:00",
+ "--calendar-id", "cal_test123",
+ "--attendee-ids", "ou_user1",
+ "--as", "bot",
+ }, f, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if attendeesStub.CapturedBody == nil {
+ t.Fatal("attendees API was not called")
+ }
+ if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_botself")) {
+ t.Fatalf("expected bot open_id ou_botself in attendees request, got: %s", attendeesStub.CapturedBody)
+ }
+ if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_user1")) {
+ t.Fatalf("expected requested attendee ou_user1 in attendees request, got: %s", attendeesStub.CapturedBody)
+ }
+}
+
+func TestCreate_WithAttendees_AsBot_BotInfoFails_ProceedsWithoutBot(t *testing.T) {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/bot/v3/info",
+ Body: map[string]interface{}{
+ "code": 99991663, "msg": "app ticket invalid",
+ },
+ })
+ 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_nobot",
+ "summary": "Bot Sync",
+ "start_time": map[string]interface{}{
+ "timestamp": "1742515200",
+ },
+ "end_time": map[string]interface{}{
+ "timestamp": "1742518800",
+ },
+ },
+ },
+ },
+ })
+ attendeesStub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/events/evt_nobot/attendees",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{},
+ },
+ }
+ reg.Register(attendeesStub)
+
+ err := mountAndRun(t, CalendarCreate, []string{
+ "+create",
+ "--summary", "Bot Sync",
+ "--start", "2025-03-21T00:00:00+08:00",
+ "--end", "2025-03-21T01:00:00+08:00",
+ "--calendar-id", "cal_test123",
+ "--attendee-ids", "ou_user1",
+ "--as", "bot",
+ }, f, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if attendeesStub.CapturedBody == nil {
+ t.Fatal("attendees API was not called")
+ }
+ if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_user1")) {
+ t.Fatalf("expected requested attendee ou_user1 in attendees request, got: %s", attendeesStub.CapturedBody)
+ }
+ if bytes.Contains(attendeesStub.CapturedBody, []byte("ou_botself")) {
+ t.Fatalf("bot open_id should be absent when /bot/v3/info fails, got: %s", attendeesStub.CapturedBody)
+ }
+}
+
func TestCreate_WithAttendees_APIError_RollsBack(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
@@ -858,9 +988,15 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured patch body: %v", err)
}
- if body["summary"] != "Updated Meeting" || body["description"] != "Updated description" {
+ // --description is the unified field, treated as rich text and sent as
+ // description_rich; the CLI never sends the plain description field
+ // (mutually exclusive downstream).
+ if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" {
t.Fatalf("unexpected patch body: %#v", body)
}
+ if _, ok := body["description"]; ok {
+ t.Fatalf("plain description must not be sent, got: %#v", body)
+ }
if body["need_notification"] != false {
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
}
@@ -1234,6 +1370,62 @@ func TestAgenda_Success(t *testing.T) {
}
}
+func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/events/instance_view",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "items": []interface{}{
+ map[string]interface{}{
+ "event_id": "evt_rich",
+ "summary": "Rich",
+ "status": "confirmed",
+ "description": "[测试]\n友情提醒",
+ "description_rich": "友情提醒",
+ "start_time": map[string]interface{}{"timestamp": "1742515200"},
+ "end_time": map[string]interface{}{"timestamp": "1742518800"},
+ },
+ map[string]interface{}{
+ "event_id": "evt_plain",
+ "summary": "Plain",
+ "status": "confirmed",
+ "description": "just text",
+ "start_time": map[string]interface{}{"timestamp": "1742515200"},
+ "end_time": map[string]interface{}{"timestamp": "1742518800"},
+ },
+ },
+ },
+ },
+ })
+
+ err := mountAndRun(t, CalendarAgenda, []string{
+ "+agenda",
+ "--start", "2025-03-21",
+ "--end", "2025-03-21",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ out := stdout.String()
+ // Read exposes a single unified description field: it carries the rich
+ // (Markdown) value when present, and the plain text otherwise. The internal
+ // description_rich key is never surfaced.
+ if !strings.Contains(out, "\"description\": \"友情提醒\"") {
+ t.Errorf("expected rich value surfaced under description, got: %s", out)
+ }
+ if !strings.Contains(out, "\"description\": \"just text\"") {
+ t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
+ }
+ if strings.Contains(out, "description_rich") {
+ t.Errorf("description_rich must not appear in output, got: %s", out)
+ }
+}
+
func TestAgenda_EmptyResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
@@ -3245,6 +3437,72 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
}
}
+func TestGet_UnifiesDescriptionRich(t *testing.T) {
+ // Read exposes a single unified description field carrying the rich value
+ // when present, and the plain text otherwise; description_rich is dropped.
+ t.Run("rich present", func(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_rich",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "success",
+ "data": map[string]interface{}{
+ "event": map[string]interface{}{
+ "event_id": "evt_rich",
+ "summary": "Rich",
+ "description": "[表格]",
+ "description_rich": "| a | b |\n| --- | --- |\n| c | d |",
+ "start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
+ "end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
+ },
+ },
+ },
+ })
+ if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_rich", "--as", "bot"}, f, stdout); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ out := stdout.String()
+ if !strings.Contains(out, "| a | b |") {
+ t.Errorf("expected rich value surfaced under description, got: %s", out)
+ }
+ if strings.Contains(out, "description_rich") {
+ t.Errorf("description_rich must not appear in output, got: %s", out)
+ }
+ })
+
+ // When only a plain description exists, it is surfaced under description.
+ t.Run("only plain surfaces under description", func(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_plain",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "success",
+ "data": map[string]interface{}{
+ "event": map[string]interface{}{
+ "event_id": "evt_plain",
+ "summary": "Plain",
+ "description": "just text",
+ "start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
+ "end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
+ },
+ },
+ },
+ })
+ if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_plain", "--as", "bot"}, f, stdout); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ out := stdout.String()
+ if !strings.Contains(out, "\"description\": \"just text\"") {
+ t.Errorf("expected plain description surfaced, got: %s", out)
+ }
+ if strings.Contains(out, "description_rich") {
+ t.Errorf("description_rich must not appear in output, got: %s", out)
+ }
+ })
+}
+
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
@@ -3368,3 +3626,952 @@ func TestGet_MissingEventField_TypedInternal(t *testing.T) {
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
}
}
+
+// ---------------------------------------------------------------------------
+// CalendarUpdate room-availability precheck tests
+// ---------------------------------------------------------------------------
+
+// eventSnapshotStub builds a GET-event fixture with the given rooms + window
+// so room-check helpers can read a plausible snapshot.
+func eventSnapshotStub(calendarID, eventID, startTs, endTs string, roomIDs ...string) *httpmock.Stub {
+ attendees := make([]interface{}, 0, len(roomIDs))
+ for _, id := range roomIDs {
+ attendees = append(attendees, map[string]interface{}{
+ "type": "resource",
+ "room_id": id,
+ })
+ }
+ return &httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/calendar/v4/calendars/" + calendarID + "/events/" + eventID,
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "event": map[string]interface{}{
+ "event_id": eventID,
+ "summary": "Existing",
+ "start_time": map[string]interface{}{"timestamp": startTs, "timezone": "Asia/Shanghai"},
+ "end_time": map[string]interface{}{"timestamp": endTs, "timezone": "Asia/Shanghai"},
+ "attendees": attendees,
+ },
+ },
+ },
+ Reusable: true,
+ }
+}
+
+func TestUpdate_RoomCheck_SkipFlag_BypassesAPI(t *testing.T) {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ // Register the PATCH stub but no room-check stub — the test asserts that no
+ // unmatched request is made.
+ patchStub := &httpmock.Stub{
+ Method: "PATCH",
+ URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc1",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{"event": map[string]interface{}{"event_id": "evt_rc1"}},
+ },
+ }
+ reg.Register(patchStub)
+
+ err := mountAndRun(t, CalendarUpdate, []string{
+ "+update",
+ "--event-id", "evt_rc1",
+ "--calendar-id", "cal_rc",
+ "--summary", "Skip",
+ "--start", "2025-03-21T00:00:00+08:00",
+ "--end", "2025-03-21T01:00:00+08:00",
+ "--skip-room-check",
+ "--as", "bot",
+ }, f, nil)
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(patchStub.CapturedBody) == 0 {
+ t.Fatalf("expected PATCH to be captured")
+ }
+}
+
+func TestUpdate_RoomCheck_TitleOnly_SkipsCheck(t *testing.T) {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ // Only registered PATCH; title-only changes should never trigger room-check
+ // and never fetch the event snapshot.
+ patchStub := &httpmock.Stub{
+ Method: "PATCH",
+ URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc2",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{"event": map[string]interface{}{"event_id": "evt_rc2"}},
+ },
+ }
+ reg.Register(patchStub)
+
+ err := mountAndRun(t, CalendarUpdate, []string{
+ "+update",
+ "--event-id", "evt_rc2",
+ "--calendar-id", "cal_rc",
+ "--summary", "New title only",
+ "--as", "bot",
+ }, f, nil)
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(patchStub.CapturedBody) == 0 {
+ t.Fatalf("expected PATCH to be captured")
+ }
+}
+
+func TestUpdate_RoomCheck_NewRoomAvailable_Allows(t *testing.T) {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ // Snapshot has no existing rooms; we're adding omm_new.
+ reg.Register(eventSnapshotStub("cal_rc", "evt_rc3", "1742515200", "1742518800"))
+
+ checkStub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "room_availabilitys": []interface{}{
+ map[string]interface{}{"room_id": "omm_new", "status": "available"},
+ },
+ },
+ },
+ }
+ reg.Register(checkStub)
+
+ addStub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc3/attendees",
+ Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
+ }
+ reg.Register(addStub)
+
+ err := mountAndRun(t, CalendarUpdate, []string{
+ "+update",
+ "--event-id", "evt_rc3",
+ "--calendar-id", "cal_rc",
+ "--add-attendee-ids", "omm_new",
+ "--as", "bot",
+ }, f, nil)
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(checkStub.CapturedBody) == 0 {
+ t.Fatalf("expected room-availability-check to be called")
+ }
+ body := decodeCalendarCapturedBody(t, checkStub)
+ rooms, _ := body["room_ids"].([]interface{})
+ if len(rooms) != 1 || rooms[0] != "omm_new" {
+ t.Fatalf("room_ids should be [omm_new], got %#v", rooms)
+ }
+ if body["calendar_id"] != "cal_rc" || body["event_id"] != "evt_rc3" {
+ t.Fatalf("room-check body missing ids: %#v", body)
+ }
+ if body["start_timezone"] != "Asia/Shanghai" {
+ t.Fatalf("start_timezone should carry snapshot value, got %#v", body["start_timezone"])
+ }
+ if body["start_time"] != "2025-03-21T08:00:00+08:00" {
+ t.Fatalf("start_time should be RFC3339 in event tz, got %#v", body["start_time"])
+ }
+ if body["end_time"] != "2025-03-21T09:00:00+08:00" {
+ t.Fatalf("end_time should be RFC3339 in event tz, got %#v", body["end_time"])
+ }
+ if len(addStub.CapturedBody) == 0 {
+ t.Fatalf("expected add-attendees POST to run")
+ }
+}
+
+func TestUpdate_RoomCheck_NewRoomUnavailable_Blocks(t *testing.T) {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ reg.Register(eventSnapshotStub("cal_rc", "evt_rc4", "1742515200", "1742518800"))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "room_availabilitys": []interface{}{
+ map[string]interface{}{
+ "room_id": "omm_busy",
+ "status": "unavailable",
+ "unavailable_reason_type": "reserved_by_other_event",
+ },
+ },
+ },
+ },
+ })
+
+ err := mountAndRun(t, CalendarUpdate, []string{
+ "+update",
+ "--event-id", "evt_rc4",
+ "--calendar-id", "cal_rc",
+ "--add-attendee-ids", "omm_busy",
+ "--as", "bot",
+ }, f, nil)
+
+ if err == nil {
+ t.Fatal("expected block error when room is unavailable")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
+ }
+ if ve.Subtype != errs.SubtypeFailedPrecondition {
+ t.Errorf("subtype=%q, want failed_precondition", ve.Subtype)
+ }
+ if !strings.Contains(ve.Message, "omm_busy") {
+ t.Errorf("message should list blocked room id, got: %q", ve.Message)
+ }
+ if !strings.Contains(ve.Hint, "--skip-room-check") {
+ t.Errorf("hint should mention --skip-room-check, got: %q", ve.Hint)
+ }
+}
+
+func TestUpdate_RoomCheck_TimeChanged_ChecksExistingRoom(t *testing.T) {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ // Existing event already has omm_existing booked.
+ reg.Register(eventSnapshotStub("cal_rc", "evt_rc5", "1742515200", "1742518800", "omm_existing"))
+
+ checkStub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "room_availabilitys": []interface{}{
+ map[string]interface{}{"room_id": "omm_existing", "status": "available"},
+ },
+ },
+ },
+ }
+ reg.Register(checkStub)
+
+ patchStub := &httpmock.Stub{
+ Method: "PATCH",
+ URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc5",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{"event": map[string]interface{}{"event_id": "evt_rc5"}},
+ },
+ }
+ reg.Register(patchStub)
+
+ err := mountAndRun(t, CalendarUpdate, []string{
+ "+update",
+ "--event-id", "evt_rc5",
+ "--calendar-id", "cal_rc",
+ "--start", "2025-03-21T02:00:00+08:00",
+ "--end", "2025-03-21T03:00:00+08:00",
+ "--as", "bot",
+ }, f, nil)
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(checkStub.CapturedBody) == 0 {
+ t.Fatalf("expected room-check to run for existing room on time change")
+ }
+ body := decodeCalendarCapturedBody(t, checkStub)
+ rooms, _ := body["room_ids"].([]interface{})
+ if len(rooms) != 1 || rooms[0] != "omm_existing" {
+ t.Fatalf("room_ids should be [omm_existing], got %#v", rooms)
+ }
+ if len(patchStub.CapturedBody) == 0 {
+ t.Fatalf("expected PATCH to run after check passes")
+ }
+}
+
+func TestUpdate_RoomCheck_APIFailure_DegradesGracefully(t *testing.T) {
+ f, _, stderr, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ reg.Register(eventSnapshotStub("cal_rc", "evt_rc6", "1742515200", "1742518800"))
+ // Simulate room-check API failure (e.g., not yet rolled out) so the CLI
+ // degrades gracefully instead of blocking the update.
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
+ Body: map[string]interface{}{
+ "code": 190001,
+ "msg": "permission denied",
+ },
+ })
+ addStub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc6/attendees",
+ Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
+ }
+ reg.Register(addStub)
+
+ err := mountAndRun(t, CalendarUpdate, []string{
+ "+update",
+ "--event-id", "evt_rc6",
+ "--calendar-id", "cal_rc",
+ "--add-attendee-ids", "omm_new",
+ "--as", "bot",
+ }, f, nil)
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(addStub.CapturedBody) == 0 {
+ t.Fatalf("expected add-attendees POST to run despite check failure")
+ }
+ if !strings.Contains(stderr.String(), "room availability check failed") {
+ t.Errorf("stderr should warn about degraded check, got: %q", stderr.String())
+ }
+}
+
+func TestUpdate_RoomCheck_DryRun_IncludesPrecheckStep(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
+
+ err := mountAndRun(t, CalendarUpdate, []string{
+ "+update",
+ "--event-id", "evt_rc7",
+ "--calendar-id", "cal_rc",
+ "--add-attendee-ids", "omm_dryrun",
+ "--start", "2025-03-21T00:00:00+08:00",
+ "--end", "2025-03-21T01:00:00+08:00",
+ "--dry-run",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ out := stdout.String()
+ if !strings.Contains(out, "room_availability_check") {
+ t.Fatalf("dry-run should preview room_availability_check, got: %s", out)
+ }
+ if !strings.Contains(out, "Pre-check meeting room availability") {
+ t.Fatalf("dry-run should describe pre-check step, got: %s", out)
+ }
+}
+
+func TestUpdate_RoomCheck_DryRun_SkipFlagOmitsStep(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
+
+ err := mountAndRun(t, CalendarUpdate, []string{
+ "+update",
+ "--event-id", "evt_rc8",
+ "--calendar-id", "cal_rc",
+ "--add-attendee-ids", "omm_dryrun2",
+ "--start", "2025-03-21T00:00:00+08:00",
+ "--end", "2025-03-21T01:00:00+08:00",
+ "--skip-room-check",
+ "--dry-run",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ out := stdout.String()
+ if strings.Contains(out, "room_availability_check") {
+ t.Fatalf("dry-run with --skip-room-check should not preview room_availability_check, got: %s", out)
+ }
+}
+
+// TestStrategyDetail_ByReason exercises the human-readable strategy suffix
+// appended to each blocked-room line. Timezone-anchored fields use a fixed
+// IANA name so the offset ("GMT+8") is deterministic across machines.
+func TestStrategyDetail_ByReason(t *testing.T) {
+ tests := []struct {
+ name string
+ reason string
+ strategy *roomStrategy
+ want string
+ }{
+ {
+ name: "over_max_duration renders as hours",
+ reason: "over_max_duration",
+ strategy: &roomStrategy{SingleMaxDuration: "10800"},
+ want: "the max single-booking duration is 3 hours",
+ },
+ {
+ name: "over_max_duration mixed hours and minutes",
+ reason: "over_max_duration",
+ strategy: &roomStrategy{SingleMaxDuration: "5400"},
+ want: "the max single-booking duration is 1 hours 30 minutes",
+ },
+ {
+ name: "beyond_advance_booking_window surfaces rfc3339 verbatim",
+ reason: "beyond_advance_booking_window",
+ strategy: &roomStrategy{MaxAdvanceBookingTime: "2026-07-13T18:00:00+08:00", Timezone: "Asia/Shanghai"},
+ want: "the latest bookable end time is 2026-07-13T18:00:00+08:00",
+ },
+ {
+ name: "not_in_usable_time renders day-seconds and zone",
+ reason: "not_in_usable_time",
+ strategy: &roomStrategy{DailyStartTime: "36000", DailyEndTime: "72000", Timezone: "Asia/Shanghai"},
+ want: "the daily bookable window is 10:00 - 20:00 (GMT+8)",
+ },
+ {
+ name: "before_daily_advance_window_release renders unlock time and zone",
+ reason: "before_daily_advance_window_release",
+ strategy: &roomStrategy{DailyAdvanceWindowReleaseTime: "28800", Timezone: "Asia/Shanghai"},
+ want: "the next unlock happens today at 08:00 (GMT+8), which advances the window by one day",
+ },
+ {
+ name: "past_time has no strategy suffix",
+ reason: "past_time",
+ strategy: &roomStrategy{SingleMaxDuration: "10800"},
+ want: "",
+ },
+ {
+ name: "nil strategy returns empty",
+ reason: "over_max_duration",
+ strategy: nil,
+ want: "",
+ },
+ {
+ name: "invalid duration returns empty",
+ reason: "over_max_duration",
+ strategy: &roomStrategy{SingleMaxDuration: "not-a-number"},
+ want: "",
+ },
+ {
+ name: "day-seconds out of range returns empty",
+ reason: "not_in_usable_time",
+ strategy: &roomStrategy{DailyStartTime: "-1", DailyEndTime: "999999", Timezone: "Asia/Shanghai"},
+ want: "",
+ },
+ {
+ name: "unresolvable timezone falls back to iana name",
+ reason: "before_daily_advance_window_release",
+ strategy: &roomStrategy{DailyAdvanceWindowReleaseTime: "28800", Timezone: "Not/AReal_Zone"},
+ want: "the next unlock happens today at 08:00 (Not/AReal_Zone), which advances the window by one day",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := strategyDetail(tt.reason, tt.strategy)
+ if got != tt.want {
+ t.Errorf("strategyDetail(%q) = %q, want %q", tt.reason, got, tt.want)
+ }
+ })
+ }
+}
+
+// TestUpdate_RoomCheck_StrategyDetailInMessage pins that when the API returns a
+// room_strategy alongside the unavailable_reason_type, blockOnUnavailableRooms
+// surfaces the specific limit inline so agents can relay it to the user
+// without an extra round trip.
+func TestUpdate_RoomCheck_StrategyDetailInMessage(t *testing.T) {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ reg.Register(eventSnapshotStub("cal_rc", "evt_rc_strategy", "1742515200", "1742525200"))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "room_availabilitys": []interface{}{
+ map[string]interface{}{
+ "room_id": "omm_toolong",
+ "status": "unavailable",
+ "unavailable_reason_type": "over_max_duration",
+ "room_strategy": map[string]interface{}{
+ "single_max_duration": "10800",
+ "timezone": "Asia/Shanghai",
+ },
+ },
+ },
+ },
+ },
+ })
+
+ err := mountAndRun(t, CalendarUpdate, []string{
+ "+update",
+ "--event-id", "evt_rc_strategy",
+ "--calendar-id", "cal_rc",
+ "--add-attendee-ids", "omm_toolong",
+ "--as", "bot",
+ }, f, nil)
+ if err == nil {
+ t.Fatal("expected block error when strategy limit is hit")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
+ }
+ if !strings.Contains(ve.Message, "the max single-booking duration is 3 hours") {
+ t.Errorf("message should surface the max-duration limit, got: %q", ve.Message)
+ }
+ if !strings.Contains(ve.Message, "omm_toolong") {
+ t.Errorf("message should still list the room id, got: %q", ve.Message)
+ }
+}
+
+// TestRequisitionDetail_ByBounds pins the human-readable suffix rendered for a
+// `during_requisition` block. Every variant (both bounds, start only, end
+// only, none, nil requisition, non-matching reason) must degrade coherently.
+func TestRequisitionDetail_ByBounds(t *testing.T) {
+ tests := []struct {
+ name string
+ req *roomRequisition
+ want string
+ }{
+ {
+ name: "both bounds surface as verbatim rfc3339 range",
+ req: &roomRequisition{StartTime: "2026-07-13T09:00:00+08:00", EndTime: "2026-07-13T18:00:00+08:00"},
+ want: "the disabled period is 2026-07-13T09:00:00+08:00 to 2026-07-13T18:00:00+08:00",
+ },
+ {
+ name: "start only",
+ req: &roomRequisition{StartTime: "2026-07-13T09:00:00+08:00"},
+ want: "the disabled period starts at 2026-07-13T09:00:00+08:00",
+ },
+ {
+ name: "end only",
+ req: &roomRequisition{EndTime: "2026-07-13T18:00:00+08:00"},
+ want: "the disabled period ends at 2026-07-13T18:00:00+08:00",
+ },
+ {
+ name: "empty bounds return no detail",
+ req: &roomRequisition{},
+ want: "",
+ },
+ {
+ name: "nil requisition returns empty",
+ req: nil,
+ want: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := requisitionDetail("during_requisition", tt.req)
+ if got != tt.want {
+ t.Errorf("requisitionDetail(during_requisition) = %q, want %q", got, tt.want)
+ }
+ })
+ }
+
+ // Non-matching reason should always short-circuit even with a full payload.
+ if got := requisitionDetail("reserved_by_other_event", &roomRequisition{StartTime: "x", EndTime: "y"}); got != "" {
+ t.Errorf("requisitionDetail should ignore requisition for non-during_requisition reasons, got %q", got)
+ }
+}
+
+// TestUpdate_RoomCheck_RequisitionDetailInMessage pins that when the API
+// returns room_requisition alongside a during_requisition block, the disabled
+// period is surfaced inline and the recovery clause is always present.
+func TestUpdate_RoomCheck_RequisitionDetailInMessage(t *testing.T) {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ reg.Register(eventSnapshotStub("cal_rc", "evt_rc_req", "1742515200", "1742525200"))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "room_availabilitys": []interface{}{
+ map[string]interface{}{
+ "room_id": "omm_req",
+ "room_name": "Meeting Room A",
+ "status": "unavailable",
+ "unavailable_reason_type": "during_requisition",
+ "room_requisition": map[string]interface{}{
+ "start_time": "2026-07-13T09:00:00+08:00",
+ "end_time": "2026-07-13T18:00:00+08:00",
+ },
+ },
+ },
+ },
+ },
+ })
+
+ err := mountAndRun(t, CalendarUpdate, []string{
+ "+update",
+ "--event-id", "evt_rc_req",
+ "--calendar-id", "cal_rc",
+ "--add-attendee-ids", "omm_req",
+ "--as", "bot",
+ }, f, nil)
+ if err == nil {
+ t.Fatal("expected block error for during_requisition")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
+ }
+ if !strings.Contains(ve.Message, "the disabled period is 2026-07-13T09:00:00+08:00 to 2026-07-13T18:00:00+08:00") {
+ t.Errorf("message should surface the disabled period, got: %q", ve.Message)
+ }
+ if !strings.Contains(ve.Message, "pick a different time or a different room") {
+ t.Errorf("message should always include recovery hint, got: %q", ve.Message)
+ }
+ if !strings.Contains(ve.Message, "omm_req[Meeting Room A]") {
+ t.Errorf("message should render room id with human-readable name, got: %q", ve.Message)
+ }
+}
+
+// TestRoomLabel_ByFields pins the room identifier rendering used in the block
+// message. `()` when both are present; degrades to
+// whichever is non-empty when the other is missing.
+func TestRoomLabel_ByFields(t *testing.T) {
+ tests := []struct {
+ name string
+ id string
+ room string
+ want string
+ }{
+ {name: "both present", id: "omm_1", room: "Meeting Room A", want: "omm_1[Meeting Room A]"},
+ {name: "id only", id: "omm_2", room: "", want: "omm_2"},
+ {name: "id only with whitespace name", id: "omm_3", room: " ", want: "omm_3"},
+ {name: "name only degrades to name", id: "", room: "Room B", want: "Room B"},
+ {name: "both blank returns empty", id: "", room: "", want: ""},
+ {name: "name with parens does not create ambiguous nesting", id: "omm_4", room: "Room A (west wing)", want: "omm_4[Room A (west wing)]"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := roomLabel(tt.id, tt.room); got != tt.want {
+ t.Errorf("roomLabel(%q, %q) = %q, want %q", tt.id, tt.room, got, tt.want)
+ }
+ })
+ }
+}
+
+// TestRecurringMasterEventID_Shapes pins the recurringMasterEventID contract:
+// only `{uid}_{positive int}` collapses to `{uid}_0`; everything else opts out.
+func TestRecurringMasterEventID_Shapes(t *testing.T) {
+ tests := []struct {
+ in string
+ wantID string
+ wantOK bool
+ scenario string
+ }{
+ {in: "abc_1742515200", wantID: "abc_0", wantOK: true, scenario: "positive suffix collapses to master"},
+ {in: "abc_1", wantID: "abc_0", wantOK: true, scenario: "positive one collapses to master"},
+ {in: "abc_0", wantID: "", wantOK: false, scenario: "already master"},
+ {in: "abc", wantID: "", wantOK: false, scenario: "no underscore"},
+ {in: "_1742515200", wantID: "", wantOK: false, scenario: "empty uid"},
+ {in: "abc_", wantID: "", wantOK: false, scenario: "empty suffix"},
+ {in: "abc_-1", wantID: "", wantOK: false, scenario: "negative suffix"},
+ {in: "abc_xyz", wantID: "", wantOK: false, scenario: "non-numeric suffix"},
+ {in: "abc_def_1742515200", wantID: "abc_def_0", wantOK: true, scenario: "uid may contain underscore"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.scenario, func(t *testing.T) {
+ gotID, gotOK := recurringMasterEventID(tt.in)
+ if gotID != tt.wantID || gotOK != tt.wantOK {
+ t.Errorf("recurringMasterEventID(%q) = (%q, %v), want (%q, %v)", tt.in, gotID, gotOK, tt.wantID, tt.wantOK)
+ }
+ })
+ }
+}
+
+// TestUpdate_RoomCheck_EventNotFound_FallsBackToMaster pins the 193001
+// fallback: when the event_id is `{uid}_{original_time}` and the server
+// answers "event not found", the snapshot GET retries against `{uid}_0`
+// (the recurring master), so the room-check pipeline can still proceed.
+func TestUpdate_RoomCheck_EventNotFound_FallsBackToMaster(t *testing.T) {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ // First GET on the instance event: 193001.
+ instanceStub := &httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/calendar/v4/calendars/cal_rc/events/uid_master_1742515200",
+ Body: map[string]interface{}{
+ "code": 193001,
+ "msg": "event not found",
+ },
+ }
+ reg.Register(instanceStub)
+
+ // Fallback GET on the master event: 200 with an existing room attendee, so
+ // the pre-check has something to reason about.
+ masterStub := &httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/calendar/v4/calendars/cal_rc/events/uid_master_0",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "event": map[string]interface{}{
+ "event_id": "uid_master_0",
+ "summary": "Weekly sync",
+ "start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
+ "end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
+ "attendees": []interface{}{map[string]interface{}{"type": "resource", "room_id": "omm_from_master"}},
+ },
+ },
+ },
+ }
+ reg.Register(masterStub)
+
+ // Time change → precheck runs against existing room from the master snapshot.
+ precheckStub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "room_availabilitys": []interface{}{
+ map[string]interface{}{
+ "room_id": "omm_from_master",
+ "status": "available",
+ },
+ },
+ },
+ },
+ }
+ reg.Register(precheckStub)
+
+ // PATCH succeeds.
+ patchStub := &httpmock.Stub{
+ Method: "PATCH",
+ URL: "/open-apis/calendar/v4/calendars/cal_rc/events/uid_master_1742515200",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{"event": map[string]interface{}{"event_id": "uid_master_1742515200"}},
+ },
+ }
+ reg.Register(patchStub)
+
+ err := mountAndRun(t, CalendarUpdate, []string{
+ "+update",
+ "--event-id", "uid_master_1742515200",
+ "--calendar-id", "cal_rc",
+ "--start", "2025-03-21T08:00:00+08:00",
+ "--end", "2025-03-21T09:00:00+08:00",
+ "--as", "bot",
+ }, f, nil)
+ if err != nil {
+ t.Fatalf("expected update to succeed after master fallback, got %v", err)
+ }
+}
+
+// TestApprovalReasonHint_ByMode pins the copy for each supported approval
+// mode, including the over_duration current-vs-threshold branches. The exact
+// phrase matters because agents parse it to decide next steps (relay to user,
+// shorten the meeting, pick another room).
+func TestApprovalReasonHint_ByMode(t *testing.T) {
+ tests := []struct {
+ name string
+ info *roomApprovalInfo
+ duration int64
+ mustContain []string
+ mustNotContain []string
+ }{
+ {
+ name: "all mode always needs approval",
+ info: &roomApprovalInfo{ApprovalMode: "all"},
+ duration: 3600,
+ mustContain: []string{
+ "requires approval for every reservation",
+ },
+ mustNotContain: []string{
+ "the CLI cannot submit approvals",
+ "lark-cli calendar event.attendees create",
+ },
+ },
+ {
+ name: "over_duration with current above threshold cites both",
+ info: &roomApprovalInfo{ApprovalMode: "over_duration", ApprovalDurationThreshold: "3600"},
+ duration: 7200,
+ mustContain: []string{
+ "exceeds 1 hours",
+ "current duration is 2 hours",
+ },
+ mustNotContain: []string{
+ "lark-cli calendar event.attendees create",
+ },
+ },
+ {
+ name: "over_duration with current exactly at threshold treated as over",
+ info: &roomApprovalInfo{ApprovalMode: "over_duration", ApprovalDurationThreshold: "3600"},
+ duration: 3600,
+ mustContain: []string{
+ "exceeds 1 hours",
+ "current duration is 1 hours",
+ },
+ },
+ {
+ name: "over_duration with current below threshold surfaces reconciliation",
+ info: &roomApprovalInfo{ApprovalMode: "over_duration", ApprovalDurationThreshold: "3600"},
+ duration: 1800,
+ mustContain: []string{
+ "exceeds 1 hours",
+ "current duration reads as 30 minutes",
+ "server still flagged approval",
+ },
+ },
+ {
+ name: "over_duration without threshold keeps mode label",
+ info: &roomApprovalInfo{ApprovalMode: "over_duration"},
+ duration: 3600,
+ mustContain: []string{
+ "exceeds a duration threshold",
+ },
+ mustNotContain: []string{
+ "the CLI cannot submit approvals",
+ },
+ },
+ {
+ name: "unknown mode falls back to generic reminder",
+ info: &roomApprovalInfo{ApprovalMode: "future_mode"},
+ duration: 3600,
+ mustContain: []string{
+ "requires approval before it can be booked",
+ },
+ },
+ {
+ name: "nil approval info still yields a reminder",
+ info: nil,
+ duration: 3600,
+ mustContain: []string{
+ "requires approval before it can be booked",
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := approvalReasonHint(tt.info, tt.duration)
+ for _, needle := range tt.mustContain {
+ if !strings.Contains(got, needle) {
+ t.Errorf("approvalReasonHint(%+v, %d) missing %q, got: %q", tt.info, tt.duration, needle, got)
+ }
+ }
+ for _, needle := range tt.mustNotContain {
+ if strings.Contains(got, needle) {
+ t.Errorf("approvalReasonHint(%+v, %d) should not contain %q (that clause belongs in the hint, not the per-line reason), got: %q", tt.info, tt.duration, needle, got)
+ }
+ }
+ })
+ }
+}
+
+// TestUpdate_RoomCheck_NeedApproval_Blocks pins that a status=="need_approval"
+// result blocks the update with a friendly, structured message: mode,
+// threshold, current duration comparison, and the "CLI can't approve" clause.
+// The block error also carries the same retry hint as the unavailable branch
+// so agents don't auto-retry with --skip-room-check.
+func TestUpdate_RoomCheck_NeedApproval_Blocks(t *testing.T) {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ // Snapshot window: 1742515200 -> 1742522400 (2h). Threshold is 1h, so the
+ // current duration is over threshold.
+ reg.Register(eventSnapshotStub("cal_rc", "evt_rc_approval", "1742515200", "1742522400"))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "room_availabilitys": []interface{}{
+ map[string]interface{}{
+ "room_id": "omm_approval",
+ "room_name": "Executive Room",
+ "status": "need_approval",
+ "room_approval_info": map[string]interface{}{
+ "approval_mode": "over_duration",
+ "approval_duration_threshold": "3600",
+ },
+ },
+ },
+ },
+ },
+ })
+
+ err := mountAndRun(t, CalendarUpdate, []string{
+ "+update",
+ "--event-id", "evt_rc_approval",
+ "--calendar-id", "cal_rc",
+ "--add-attendee-ids", "omm_approval",
+ "--as", "bot",
+ }, f, nil)
+ if err == nil {
+ t.Fatal("expected need_approval to block the update")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
+ }
+ if !strings.Contains(ve.Message, "omm_approval[Executive Room]") {
+ t.Errorf("message should render room label, got: %q", ve.Message)
+ }
+ if !strings.Contains(ve.Message, "requires approval when the booking exceeds 1 hours") {
+ t.Errorf("message should carry approval threshold, got: %q", ve.Message)
+ }
+ if !strings.Contains(ve.Message, "current duration is 2 hours") {
+ t.Errorf("message should carry current-vs-threshold comparison, got: %q", ve.Message)
+ }
+ if strings.Contains(ve.Message, "the CLI cannot submit approvals inline") {
+ t.Errorf("recovery clause should live in the hint (not repeated per line in the message), got message: %q", ve.Message)
+ }
+ if strings.Contains(ve.Message, "lark-cli calendar event.attendees create --as user") {
+ t.Errorf("attendees-create recovery clause should live in the hint (not per line), got message: %q", ve.Message)
+ }
+ if !strings.Contains(ve.Hint, "the CLI cannot submit approvals") {
+ t.Errorf("hint should carry the approval recovery clause once, got: %q", ve.Hint)
+ }
+ if !strings.Contains(ve.Hint, "DO NOT auto-run") {
+ t.Errorf("hint should forbid auto-running any approval recovery path without user confirmation, got: %q", ve.Hint)
+ }
+ if !strings.Contains(ve.Hint, "ask the user first") {
+ t.Errorf("hint should require asking the user before picking a recovery path, got: %q", ve.Hint)
+ }
+ if !strings.Contains(ve.Hint, "lark-cli calendar event.attendees create --as user") {
+ t.Errorf("hint should point at the attendees-create recovery path, got: %q", ve.Hint)
+ }
+ if !strings.Contains(ve.Hint, "update through the client") {
+ t.Errorf("hint should mention the client-side fallback for re-approval on existing rooms, got: %q", ve.Hint)
+ }
+ if !strings.Contains(ve.Hint, flagSkipRoomCheck) {
+ t.Errorf("hint should still mention --%s, got: %q", flagSkipRoomCheck, ve.Hint)
+ }
+}
+
+// TestUpdate_RoomCheck_RequisitionMissingBoundsStillCoherent pins that when
+// the API returns during_requisition without room_requisition, the recovery
+// hint keeps the line coherent on its own.
+func TestUpdate_RoomCheck_RequisitionMissingBoundsStillCoherent(t *testing.T) {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ reg.Register(eventSnapshotStub("cal_rc", "evt_rc_req2", "1742515200", "1742525200"))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "ok",
+ "data": map[string]interface{}{
+ "room_availabilitys": []interface{}{
+ map[string]interface{}{
+ "room_id": "omm_req_nobounds",
+ "status": "unavailable",
+ "unavailable_reason_type": "during_requisition",
+ },
+ },
+ },
+ },
+ })
+
+ err := mountAndRun(t, CalendarUpdate, []string{
+ "+update",
+ "--event-id", "evt_rc_req2",
+ "--calendar-id", "cal_rc",
+ "--add-attendee-ids", "omm_req_nobounds",
+ "--as", "bot",
+ }, f, nil)
+ if err == nil {
+ t.Fatal("expected block error for during_requisition without bounds")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
+ }
+ if strings.Contains(ve.Message, "the disabled period") {
+ t.Errorf("message should not fabricate a disabled period, got: %q", ve.Message)
+ }
+ if !strings.Contains(ve.Message, "pick a different time or a different room") {
+ t.Errorf("message should always include recovery hint, got: %q", ve.Message)
+ }
+}
diff --git a/shortcuts/calendar/calendar_update.go b/shortcuts/calendar/calendar_update.go
index 17f9cc897..a8fdc508a 100644
--- a/shortcuts/calendar/calendar_update.go
+++ b/shortcuts/calendar/calendar_update.go
@@ -29,13 +29,14 @@ var CalendarUpdate = common.Shortcut{
{Name: "event-id", Desc: "event ID to update", Required: true},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "summary", Desc: "event title"},
- {Name: "description", Desc: "event description"},
+ {Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `
`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a
2. b`, `- x
- y`, `
**bold**`). Passing an empty string clears the description.", Input: []string{common.File, common.Stdin}},
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
{Name: "add-attendee-ids", Desc: "attendee IDs to add, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "remove-attendee-ids", Desc: "attendee IDs to remove, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "notify", Type: "bool", Default: "true", Desc: "send update notification to attendees"},
+ {Name: flagSkipRoomCheck, Type: "bool", Default: "false", Hidden: true, Desc: "skip meeting-room availability precheck (default checks rooms whenever a new room is added or the time/rrule of a room-attached event changes)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateCalendarUpdate(runtime)
@@ -108,11 +109,13 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
body := map[string]interface{}{}
hasFields := false
- for _, field := range []string{"summary", "description"} {
- if runtime.Cmd.Flags().Changed(field) {
- body[field] = runtime.Str(field)
- hasFields = true
- }
+ if runtime.Cmd.Flags().Changed("summary") {
+ body["summary"] = runtime.Str("summary")
+ hasFields = true
+ }
+ if runtime.Cmd.Flags().Changed("description") {
+ body["description_rich"] = runtime.Str("description")
+ hasFields = true
}
if runtime.Cmd.Flags().Changed("rrule") {
rrule := strings.TrimSpace(runtime.Str("rrule"))
@@ -219,6 +222,50 @@ func calendarUpdateAttendeesPath(calendarID, eventID string) string {
return calendarUpdateEventPath(calendarID, eventID) + "/attendees"
}
+// runRoomAvailabilityPrecheck checks any room affected by this update (new
+// room attendees, or existing rooms when the time/rrule shifts) against the
+// server before the PATCH is issued. It returns nil to allow the update to
+// proceed and a typed error to block it. Called only when --skip-room-check
+// is false.
+func runRoomAvailabilityPrecheck(ctx context.Context, runtime *common.RuntimeContext, calendarID, eventID string, body map[string]interface{}) error {
+ timeChanged := runtime.Cmd.Flags().Changed("start") && runtime.Cmd.Flags().Changed("end")
+ rruleChanged := runtime.Cmd.Flags().Changed("rrule")
+
+ var newStartTs, newEndTs string
+ if timeChanged {
+ if m, _ := body["start_time"].(map[string]string); m != nil {
+ newStartTs = m["timestamp"]
+ }
+ if m, _ := body["end_time"].(map[string]string); m != nil {
+ newEndTs = m["timestamp"]
+ }
+ }
+
+ plan, err := resolveRoomCheckPlan(ctx, runtime, calendarID, eventID, newStartTs, newEndTs, timeChanged, rruleChanged)
+ if err != nil {
+ return err
+ }
+ if plan == nil {
+ return nil
+ }
+ results, err := callRoomAvailabilityCheck(runtime, buildRoomCheckBody(calendarID, eventID, plan))
+ if err != nil {
+ // Degrade gracefully: warn on stderr and let the update proceed so the
+ // pre-check API doesn't gate legitimate updates when it hiccups. For
+ // 190014 (invalid_parameters) surface the server-supplied field-level
+ // detail so agents can see why the precheck refused.
+ msg := unwrapCalendarAPIError(err)
+ if msg == "" {
+ msg = err.Error()
+ }
+ fmt.Fprintf(runtime.IO().ErrOut,
+ "[calendar +update] warning: room availability check failed (%s); proceeding with update — pass --%s to silence\n",
+ msg, flagSkipRoomCheck)
+ return nil
+ }
+ return blockOnUnavailableRooms(results, roomCheckPlanDurationSec(plan))
+}
+
func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
calendarID, eventID := calendarUpdateIDs(runtime)
displayCalendarID := calendarID
@@ -246,6 +293,33 @@ func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
d.Desc("multi-step update: event fields, attendee removal, and attendee addition run in order when requested")
}
steps := 0
+
+ if !runtime.Bool(flagSkipRoomCheck) {
+ newRooms := collectAttendeeRoomIDs(runtime.Str("add-attendee-ids"))
+ timeChanged := runtime.Cmd.Flags().Changed("start") && runtime.Cmd.Flags().Changed("end")
+ rruleChanged := runtime.Cmd.Flags().Changed("rrule")
+ if len(newRooms) > 0 || timeChanged || rruleChanged {
+ steps++
+ desc := fmt.Sprintf("[%d] Pre-check meeting room availability (default; pass --%s to skip)", steps, flagSkipRoomCheck)
+ previewBody := map[string]interface{}{
+ "calendar_id": displayCalendarID,
+ "event_id": eventID,
+ "room_ids": newRooms,
+ "start_timezone": "",
+ }
+ if start, _ := body["start_time"].(map[string]string); start != nil {
+ previewBody["start_time"] = formatRoomCheckTime(start["timestamp"], time.Local)
+ }
+ if end, _ := body["end_time"].(map[string]string); end != nil {
+ previewBody["end_time"] = formatRoomCheckTime(end["timestamp"], time.Local)
+ }
+ if rrule, _ := body["recurrence"].(string); rrule != "" {
+ previewBody["event_rrule"] = rrule
+ }
+ d.POST(roomCheckPath).Desc(desc).Body(previewBody)
+ }
+ }
+
if hasEventFields {
steps++
d.PATCH("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
@@ -278,17 +352,29 @@ func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
return d
}
-func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) error {
+func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext) error {
calendarID, eventID := calendarUpdateIDs(runtime)
if eventID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
}
+ if runtime.Cmd.Flags().Changed("description") {
+ if err := resolveDescriptionImages(runtime, calendarID); err != nil {
+ return err
+ }
+ }
+
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
if err != nil {
return err
}
+ if !runtime.Bool(flagSkipRoomCheck) {
+ if err := runRoomAvailabilityPrecheck(ctx, runtime, calendarID, eventID, body); err != nil {
+ return err
+ }
+ }
+
completed := []string{}
event := map[string]interface{}{}
if hasEventFields {
@@ -350,8 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
if summary, _ := event["summary"].(string); summary != "" {
result["summary"] = summary
}
- if description, _ := event["description"].(string); description != "" {
- result["description"] = description
+ if rich, _ := event["description_rich"].(string); rich != "" {
+ result["description"] = rich
+ } else if plain, _ := event["description"].(string); plain != "" {
+ result["description"] = plain
}
if start := formatCalendarEventTime(event["start_time"]); start != "" {
result["start"] = start
diff --git a/shortcuts/calendar/description_rich_images.go b/shortcuts/calendar/description_rich_images.go
new file mode 100644
index 000000000..719e24ea2
--- /dev/null
+++ b/shortcuts/calendar/description_rich_images.go
@@ -0,0 +1,172 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package calendar
+
+import (
+ "fmt"
+ "image"
+
+ // Register the common image decoders so DecodeConfig can read intrinsic
+ // dimensions for PNG/JPEG/GIF sources.
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+const calendarMediaParentType = "calendar"
+
+var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
+
+func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
+ md := runtime.Str("description")
+ if md == "" || !strings.Contains(md, "![") {
+ return nil
+ }
+ rewritten, changed, err := uploadLocalDescriptionImages(runtime, calendarID, md)
+ if err != nil {
+ return err
+ }
+ if changed {
+ if err := runtime.Cmd.Flags().Set("description", rewritten); err != nil {
+ return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description after image upload: %v", err).WithCause(err)
+ }
+ }
+ return nil
+}
+
+func uploadLocalDescriptionImages(runtime *common.RuntimeContext, calendarID, md string) (string, bool, error) {
+ matches := markdownImageRe.FindAllStringSubmatchIndex(md, -1)
+ if len(matches) == 0 {
+ return md, false, nil
+ }
+ var out strings.Builder
+ last := 0
+ changed := false
+ cache := map[string]string{}
+ for _, m := range matches {
+ altStart, altEnd, srcStart, srcEnd := m[2], m[3], m[4], m[5]
+ src := strings.TrimSpace(md[srcStart:srcEnd])
+ if !isLocalImageSrc(src) {
+ continue
+ }
+ alt := md[altStart:altEnd]
+ uploadedURL, err := resolveLocalImage(runtime, calendarID, src, alt, cache)
+ if err != nil {
+ return "", false, err
+ }
+ out.WriteString(md[last:srcStart])
+ out.WriteString(uploadedURL)
+ last = srcEnd
+ changed = true
+ }
+ if !changed {
+ return md, false, nil
+ }
+ out.WriteString(md[last:])
+ return out.String(), true, nil
+}
+
+func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt string, cache map[string]string) (string, error) {
+ localPath := localImagePath(src)
+ if cached, ok := cache[localPath]; ok {
+ return cached, nil
+ }
+
+ safePath, err := validate.SafeInputPath(localPath)
+ if err != nil {
+ return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
+ "--description image %q could not be read: %v", src, err).
+ WithParam("--description").
+ WithHint("reference local images by a path inside the current working directory (e.g. ./images/pic.png; cd there first), or use an already-uploaded Lark image URL").
+ WithCause(err)
+ }
+
+ info, err := runtime.FileIO().Stat(localPath)
+ if err != nil {
+ return "", common.WrapInputStatErrorTyped(err)
+ }
+
+ fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
+ FilePath: localPath,
+ FileName: filepath.Base(safePath),
+ FileSize: info.Size(),
+ ParentType: calendarMediaParentType,
+ ParentNode: &calendarID,
+ })
+ if err != nil {
+ return "", err
+ }
+
+ width, height := decodeImageDimensions(runtime, localPath)
+ uploadedURL := buildCalendarImagePreviewURL(runtime.Config.Brand, fileToken, width, height, info.Size())
+ cache[localPath] = uploadedURL
+ return uploadedURL, nil
+}
+
+func decodeImageDimensions(runtime *common.RuntimeContext, path string) (int, int) {
+ f, err := runtime.FileIO().Open(path)
+ if err != nil {
+ return 0, 0
+ }
+ defer f.Close()
+ cfg, _, err := image.DecodeConfig(f)
+ if err != nil {
+ return 0, 0
+ }
+ return cfg.Width, cfg.Height
+}
+
+func isLocalImageSrc(src string) bool {
+ if src == "" {
+ return false
+ }
+ lower := strings.ToLower(src)
+ switch {
+ case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "data:"):
+ return false
+ case strings.HasPrefix(lower, "file://"):
+ return true
+ }
+ if i := strings.Index(src, "://"); i > 0 {
+ return false
+ }
+ return true
+}
+
+func localImagePath(src string) string {
+ s := strings.TrimSpace(src)
+ if strings.HasPrefix(strings.ToLower(s), "file://") {
+ if u, err := url.Parse(s); err == nil && u.Path != "" {
+ s = u.Path
+ }
+ }
+ if decoded, err := url.PathUnescape(s); err == nil {
+ return decoded
+ }
+ return s
+}
+
+func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string {
+ host := "internal-api-drive-stream.feishu.cn"
+ if brand == core.BrandLark {
+ host = "internal-api-drive-stream.larksuite.com"
+ }
+ u := fmt.Sprintf("https://%s/space/api/box/stream/download/preview/%s?preview_type=16", host, fileToken)
+ if width > 0 && height > 0 {
+ u += fmt.Sprintf("&im_w=%d&im_h=%d", width, height)
+ }
+ if size > 0 {
+ u += fmt.Sprintf("&im_size=%d", size)
+ }
+ return u
+}
diff --git a/shortcuts/calendar/description_rich_images_test.go b/shortcuts/calendar/description_rich_images_test.go
new file mode 100644
index 000000000..e41c24b4b
--- /dev/null
+++ b/shortcuts/calendar/description_rich_images_test.go
@@ -0,0 +1,279 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package calendar
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "image"
+ "image/png"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/internal/httpmock"
+)
+
+func TestIsLocalImageSrc(t *testing.T) {
+ cases := []struct {
+ src string
+ want bool
+ }{
+ {"./images/pic.png", true},
+ {"images/pic.png", true},
+ {"../assets/a.png", true},
+ {"/Users/me/Desktop/a.png", true},
+ {`C:\Users\me\a.png`, true},
+ {"file:///Users/me/a.png", true},
+ {"图片和附件/测试图片.png", true},
+ {"https://example.com/a.png", false},
+ {"http://example.com/a.png", false},
+ {"HTTPS://EXAMPLE.com/a.png", false},
+ {"data:image/png;base64,iVBOR", false},
+ {"ftp://host/a.png", false},
+ {"", false},
+ }
+ for _, c := range cases {
+ if got := isLocalImageSrc(c.src); got != c.want {
+ t.Errorf("isLocalImageSrc(%q) = %v, want %v", c.src, got, c.want)
+ }
+ }
+}
+
+func TestLocalImagePath(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"images/pic.png", "images/pic.png"},
+ {"images/my%20pic.png", "images/my pic.png"},
+ {"file:///Users/me/a.png", "/Users/me/a.png"},
+ }
+ for _, c := range cases {
+ if got := localImagePath(c.in); got != c.want {
+ t.Errorf("localImagePath(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+// TestBuildCalendarImagePreviewURL guards the contract the OpenAPI service
+// relies on: a Lark host (so token extraction triggers) whose final path
+// segment is exactly the uploaded file token.
+func TestBuildCalendarImagePreviewURL(t *testing.T) {
+ for _, tc := range []struct {
+ brand core.LarkBrand
+ hostFrag string
+ }{
+ {core.BrandFeishu, "feishu.cn"},
+ {core.BrandLark, "larksuite"},
+ } {
+ raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568)
+ u, err := url.Parse(raw)
+ if err != nil {
+ t.Fatalf("built URL not parseable: %v", err)
+ }
+ if !strings.Contains(u.Host, tc.hostFrag) {
+ t.Errorf("brand %s host = %q, want fragment %q", tc.brand, u.Host, tc.hostFrag)
+ }
+ segs := strings.Split(strings.Trim(u.Path, "/"), "/")
+ if last := segs[len(segs)-1]; last != "boxcnTOKEN123" {
+ t.Errorf("last path segment = %q, want token", last)
+ }
+ q := u.Query()
+ if q.Get("im_w") != "416" || q.Get("im_h") != "306" || q.Get("im_size") != "142568" {
+ t.Errorf("dimension params missing: im_w=%q im_h=%q im_size=%q", q.Get("im_w"), q.Get("im_h"), q.Get("im_size"))
+ }
+ }
+
+ // With unknown dimensions the helper params are omitted entirely.
+ raw := buildCalendarImagePreviewURL(core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0)
+ if strings.Contains(raw, "im_w") || strings.Contains(raw, "im_size") {
+ t.Errorf("expected no dimension params for unknown size, got %q", raw)
+ }
+}
+
+// TestUploadLocalDescriptionImages_RemoteUntouched verifies remote/data images
+// pass through unchanged and never trigger an upload (runtime unused → nil).
+func TestUploadLocalDescriptionImages_RemoteUntouched(t *testing.T) {
+ md := "text  more "
+ got, changed, err := uploadLocalDescriptionImages(nil, "cal", md)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if changed {
+ t.Errorf("changed = true, want false")
+ }
+ if got != md {
+ t.Errorf("markdown mutated: %q", got)
+ }
+}
+
+// TestCreate_UploadsLocalDescriptionImage runs +create with a local image path,
+// mocks the drive upload, and asserts the create body's description_rich carries
+// the uploaded token (not the local path).
+func TestCreate_UploadsLocalDescriptionImage(t *testing.T) {
+ dir := t.TempDir()
+ orig, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chdir(dir); err != nil {
+ t.Fatal(err)
+ }
+ defer os.Chdir(orig)
+ if err := os.WriteFile(filepath.Join(dir, "pic.png"), []byte("PNGDATA"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ uploadStub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/medias/upload_all",
+ Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
+ }
+ reg.Register(uploadStub)
+
+ createStub := &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_001",
+ "summary": "Pic",
+ "start_time": map[string]interface{}{"timestamp": "1742515200"},
+ "end_time": map[string]interface{}{"timestamp": "1742518800"},
+ },
+ }},
+ }
+ reg.Register(createStub)
+
+ runErr := mountAndRun(t, CalendarCreate, []string{
+ "+create",
+ "--summary", "Pic",
+ "--start", "2025-03-21T00:00:00+08:00",
+ "--end", "2025-03-21T01:00:00+08:00",
+ "--calendar-id", "cal_test123",
+ "--description", "",
+ "--as", "bot",
+ }, f, stdout)
+ if runErr != nil {
+ t.Fatalf("unexpected error: %v", runErr)
+ }
+
+ if uploadStub.CapturedBody == nil {
+ t.Fatalf("expected drive upload to be called")
+ }
+ if createStub.CapturedBody == nil {
+ t.Fatalf("expected create event to be called")
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
+ t.Fatalf("create body unmarshal: %v", err)
+ }
+ dr, _ := body["description_rich"].(string)
+ if !strings.Contains(dr, "boxcnTOKEN123") {
+ t.Fatalf("description_rich should contain uploaded token, got %q", dr)
+ }
+ if strings.Contains(dr, "./pic.png") {
+ t.Fatalf("local path should be rewritten away, got %q", dr)
+ }
+}
+
+// TestCreate_LocalImageCarriesDimensions verifies a real decodable image's
+// intrinsic width/height and byte size are appended to the rewritten drive URL
+// (so the facade can populate originalWidth/originalHeight and the client can
+// render the image inline).
+func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
+ dir := t.TempDir()
+ orig, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chdir(dir); err != nil {
+ t.Fatal(err)
+ }
+ defer os.Chdir(orig)
+
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 5, 7))); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "pic.png"), buf.Bytes(), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/medias/upload_all",
+ Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
+ })
+ createStub := &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_001",
+ "summary": "Pic",
+ "start_time": map[string]interface{}{"timestamp": "1742515200"},
+ "end_time": map[string]interface{}{"timestamp": "1742518800"},
+ },
+ }},
+ }
+ reg.Register(createStub)
+
+ runErr := mountAndRun(t, CalendarCreate, []string{
+ "+create",
+ "--summary", "Pic",
+ "--start", "2025-03-21T00:00:00+08:00",
+ "--end", "2025-03-21T01:00:00+08:00",
+ "--calendar-id", "cal_test123",
+ "--description", "",
+ "--as", "bot",
+ }, f, stdout)
+ if runErr != nil {
+ t.Fatalf("unexpected error: %v", runErr)
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
+ t.Fatalf("create body unmarshal: %v", err)
+ }
+ dr, _ := body["description_rich"].(string)
+ if !strings.Contains(dr, "im_w=5") || !strings.Contains(dr, "im_h=7") {
+ t.Fatalf("description_rich should carry image dimensions, got %q", dr)
+ }
+ if !strings.Contains(dr, "im_size=") {
+ t.Fatalf("description_rich should carry image byte size, got %q", dr)
+ }
+}
+
+// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path
+// yields a typed --description validation error before any API call.
+func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
+
+ runErr := mountAndRun(t, CalendarCreate, []string{
+ "+create",
+ "--summary", "Pic",
+ "--start", "2025-03-21T00:00:00+08:00",
+ "--end", "2025-03-21T01:00:00+08:00",
+ "--calendar-id", "cal_test123",
+ "--description", "",
+ "--as", "bot",
+ }, f, stdout)
+ if runErr == nil {
+ t.Fatalf("expected error for absolute image path")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(runErr, &ve) {
+ t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr)
+ }
+ if ve.Param != "--description" {
+ t.Errorf("param = %q, want --description", ve.Param)
+ }
+}
diff --git a/shortcuts/calendar/helpers.go b/shortcuts/calendar/helpers.go
index b9511fea4..7a797fb92 100644
--- a/shortcuts/calendar/helpers.go
+++ b/shortcuts/calendar/helpers.go
@@ -30,6 +30,26 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
return startInput, endInput
}
+func collapseDescription(event map[string]interface{}) {
+ if event == nil {
+ return
+ }
+ rich, _ := event["description_rich"].(string)
+ plain, _ := event["description"].(string)
+ delete(event, "description_rich")
+ switch {
+ case rich != "":
+ event["description"] = rich
+ case plain != "":
+ event["description"] = plain
+ default:
+ delete(event, "description")
+ }
+}
+func descriptionToSend(runtime *common.RuntimeContext) string {
+ return runtime.Str("description")
+}
+
func hasExplicitBotFlag(cmd *cobra.Command) bool {
if cmd == nil {
return false
diff --git a/shortcuts/common/localfile.go b/shortcuts/common/localfile.go
new file mode 100644
index 000000000..0528791d9
--- /dev/null
+++ b/shortcuts/common/localfile.go
@@ -0,0 +1,146 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package common
+
+import (
+ "errors"
+ "io"
+ "io/fs"
+ "math"
+ "strings"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/extension/fileio"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/validate"
+)
+
+// ValidateLocalFileFlag validates that a local input path exists, is a regular
+// file, and does not exceed maxBytes. Absolute and relative paths use
+// the process filesystem namespace.
+func (ctx *RuntimeContext) ValidateLocalFileFlag(flagName string, maxBytes int64) error {
+ path, param, err := ctx.localFileFlag(flagName, maxBytes)
+ if err != nil {
+ return err
+ }
+
+ info, err := cmdutil.StatLocalFile(path)
+ if err != nil {
+ return localFileReadError(param, path, "inspect", err)
+ }
+ if err := localFileRegularError(param, path, info.Mode()); err != nil {
+ return err
+ }
+ if info.Size() > maxBytes {
+ return localFileSizeError(param, path, info.Size(), maxBytes)
+ }
+ return nil
+}
+
+// ReadLocalFileFlag is the shared replacement for direct os.ReadFile calls in
+// shortcuts. It accepts absolute and relative paths, enforces a hard size
+// limit, and returns command-facing typed errors.
+func (ctx *RuntimeContext) ReadLocalFileFlag(flagName string, maxBytes int64) (data []byte, retErr error) {
+ path, param, err := ctx.localFileFlag(flagName, maxBytes)
+ if err != nil {
+ return nil, err
+ }
+ f, err := cmdutil.OpenLocalFile(path)
+ if err != nil {
+ return nil, localFileReadError(param, path, "open", err)
+ }
+ defer func() {
+ if err := f.Close(); err != nil && retErr == nil {
+ data = nil
+ retErr = errs.NewInternalError(errs.SubtypeFileIO, "cannot close %s %q: %v", param, path, err).WithCause(err)
+ }
+ }()
+
+ openedInfo, err := f.Stat()
+ if err != nil {
+ return nil, localFileReadError(param, path, "inspect opened", err)
+ }
+ if err := localFileRegularError(param, path, openedInfo.Mode()); err != nil {
+ return nil, err
+ }
+ if openedInfo.Size() > maxBytes {
+ return nil, localFileSizeError(param, path, openedInfo.Size(), maxBytes)
+ }
+
+ readLimit := maxBytes + 1
+ if maxBytes == math.MaxInt64 {
+ readLimit = maxBytes
+ }
+ data, err = io.ReadAll(io.LimitReader(f, readLimit))
+ if err != nil {
+ return nil, localFileReadError(param, path, "read", err)
+ }
+ if int64(len(data)) > maxBytes {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
+ "%s %q grew beyond the %d-byte limit while being read", param, path, maxBytes).
+ WithParam(param)
+ }
+ return data, nil
+}
+
+func (ctx *RuntimeContext) localFileFlag(flagName string, maxBytes int64) (path, param string, err error) {
+ name, param, err := localFileFlagNames(flagName)
+ if err != nil {
+ return "", "", err
+ }
+ if ctx == nil || ctx.Cmd == nil {
+ return "", param, errs.NewInternalError(errs.SubtypeUnknown, "cannot read %s: runtime command is unavailable", param)
+ }
+
+ path = strings.TrimSpace(ctx.Str(name))
+ if path == "" {
+ return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required", param).WithParam(param)
+ }
+ if _, err := validate.LocalInputPath(path); err != nil {
+ return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s path: %v", param, err).
+ WithParam(param).
+ WithCause(err)
+ }
+ if maxBytes < 0 {
+ return "", param, errs.NewInternalError(errs.SubtypeUnknown, "invalid read limit configured for %s", param)
+ }
+ return path, param, nil
+}
+
+func localFileRegularError(param, path string, mode fs.FileMode) error {
+ if mode.IsRegular() {
+ return nil
+ }
+ return errs.NewValidationError(errs.SubtypeInvalidArgument,
+ "%s %q is not a regular file", param, path).
+ WithParam(param)
+}
+
+func localFileReadError(param, path, op string, cause error) error {
+ if errors.Is(cause, fileio.ErrPathValidation) {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s %q: %v", param, path, cause).
+ WithParam(param).
+ WithCause(cause)
+ }
+ if errors.Is(cause, fs.ErrNotExist) {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s %q does not exist", param, path).
+ WithParam(param).
+ WithCause(cause)
+ }
+ return errs.NewInternalError(errs.SubtypeFileIO, "cannot %s %s %q: %v", op, param, path, cause).WithCause(cause)
+}
+
+func localFileSizeError(param, path string, size, limit int64) error {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument,
+ "%s %q is %d bytes; limit is %d bytes", param, path, size, limit).
+ WithParam(param)
+}
+
+func localFileFlagNames(flagName string) (name, param string, err error) {
+ name = strings.TrimLeft(strings.TrimSpace(flagName), "-")
+ if name == "" {
+ return "", "", errs.NewInternalError(errs.SubtypeUnknown, "local file flag name must not be empty")
+ }
+ return name, "--" + name, nil
+}
diff --git a/shortcuts/common/localfile_test.go b/shortcuts/common/localfile_test.go
new file mode 100644
index 000000000..f6c1c6f84
--- /dev/null
+++ b/shortcuts/common/localfile_test.go
@@ -0,0 +1,95 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package common
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/spf13/cobra"
+)
+
+func TestReadLocalFileFlag_AcceptsAbsolutePath(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "input.txt")
+ if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ rctx := localFileTestRuntime(t, path)
+
+ if err := rctx.ValidateLocalFileFlag("file", 7); err != nil {
+ t.Fatalf("ValidateLocalFileFlag() error = %v", err)
+ }
+ got, err := rctx.ReadLocalFileFlag("file", 7)
+ if err != nil || string(got) != "content" {
+ t.Fatalf("ReadLocalFileFlag() = %q, %v; want content", got, err)
+ }
+}
+
+func TestValidateLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ path func(t *testing.T) string
+ max int64
+ }{
+ {name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
+ {name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
+ {name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
+ {name: "too large", path: func(t *testing.T) string {
+ path := filepath.Join(t.TempDir(), "large")
+ if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return path
+ }, max: 5},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ err := localFileTestRuntime(t, tc.path(t)).ValidateLocalFileFlag("file", tc.max)
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
+ t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
+ }
+ })
+ }
+}
+
+func TestReadLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ path func(t *testing.T) string
+ max int64
+ }{
+ {name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
+ {name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
+ {name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
+ {name: "too large", path: func(t *testing.T) string {
+ path := filepath.Join(t.TempDir(), "large")
+ if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return path
+ }, max: 5},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := localFileTestRuntime(t, tc.path(t)).ReadLocalFileFlag("file", tc.max)
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
+ t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
+ }
+ })
+ }
+}
+
+func localFileTestRuntime(t *testing.T, path string) *RuntimeContext {
+ t.Helper()
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("file", "", "")
+ if err := cmd.Flags().Set("file", path); err != nil {
+ t.Fatal(err)
+ }
+ return &RuntimeContext{ctx: context.Background(), Cmd: cmd}
+}
diff --git a/shortcuts/common/permission_grant.go b/shortcuts/common/permission_grant.go
index 4b6fd890e..0e2eea081 100644
--- a/shortcuts/common/permission_grant.go
+++ b/shortcuts/common/permission_grant.go
@@ -14,11 +14,10 @@ import (
)
const (
- PermissionGrantGranted = "granted"
- PermissionGrantSkipped = "skipped"
- PermissionGrantFailed = "failed"
- permissionGrantPerm = "full_access"
- permissionGrantPermHint = "可管理权限"
+ PermissionGrantGranted = "granted"
+ PermissionGrantSkipped = "skipped"
+ PermissionGrantFailed = "failed"
+ permissionGrantPerm = "full_access"
)
// AutoGrantCurrentUserDrivePermission grants full_access on a newly created
@@ -121,7 +120,7 @@ func buildPermissionGrantResult(status, userOpenID, message, reason string) map[
}
func permissionGrantPermMessage() string {
- return permissionGrantPerm + " (" + permissionGrantPermHint + ")"
+ return permissionGrantPerm
}
func permissionGrantPermType(resourceType string) string {
diff --git a/shortcuts/common/permission_grant_test.go b/shortcuts/common/permission_grant_test.go
index 15f327102..abd966b65 100644
--- a/shortcuts/common/permission_grant_test.go
+++ b/shortcuts/common/permission_grant_test.go
@@ -31,6 +31,14 @@ func apiErrWithScopes(code int, msg string, subjects ...string) error {
return errclass.BuildAPIError(resp, errclass.ClassifyContext{})
}
+func TestPermissionGrantPermMessageUsesAPINameOnly(t *testing.T) {
+ t.Parallel()
+
+ if got := permissionGrantPermMessage(); got != "full_access" {
+ t.Fatalf("permissionGrantPermMessage() = %q, want %q", got, "full_access")
+ }
+}
+
func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) {
config := &core.CliConfig{
AppID: "perm-grant-test-skip",
diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go
index 926930f6f..5ac338a0d 100644
--- a/shortcuts/common/runner.go
+++ b/shortcuts/common/runner.go
@@ -666,16 +666,59 @@ func (ctx *RuntimeContext) ValidatePath(path string) error {
// ── Output helpers ──
+func (ctx *RuntimeContext) newEmitter() *output.Emitter {
+ streams := ctx.IO()
+ return output.NewEmitter(output.EmitterConfig{
+ Out: streams.Out,
+ ErrOut: streams.ErrOut,
+ CommandPath: ctx.Cmd.CommandPath(),
+ Identity: string(ctx.As()),
+ ColorEnabled: streams.OutIsTerminal,
+ NoticeProvider: output.GetNotice,
+ })
+}
+
+func (ctx *RuntimeContext) handleEmitterError(err error) {
+ if err == nil {
+ return
+ }
+ var cs *errs.ContentSafetyError
+ if ctx.JqExpr != "" && !errors.As(err, &cs) {
+ fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
+ }
+ ctx.outputErrOnce.Do(func() { ctx.outputErr = err })
+}
+
+func wrapLegacyPrettyRenderer(prettyFn func(w io.Writer)) output.PrettyRenderer {
+ if prettyFn == nil {
+ return nil
+ }
+ return func(w io.Writer, _ bool) error {
+ prettyFn(w)
+ return nil
+ }
+}
+
// Out prints a success JSON envelope to stdout.
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
- ctx.emit(data, meta, false, true)
+ ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
+ Format: "",
+ Raw: false,
+ JQ: ctx.JqExpr,
+ Meta: meta,
+ }))
}
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
// that should be preserved as-is in JSON output.
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
- ctx.emit(data, meta, true, true)
+ ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
+ Format: "",
+ Raw: true,
+ JQ: ctx.JqExpr,
+ Meta: meta,
+ }))
}
// OutPartialFailure writes an ok:false multi-status result envelope to stdout
@@ -689,112 +732,42 @@ func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
// ok:true, and the exit signal is distinct from ErrBare (the
// stdout-carries-the-answer silent-exit signal).
func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta) error {
- ctx.emit(data, meta, false, false)
+ ctx.handleEmitterError(ctx.newEmitter().PartialFailure(data, output.EmitOptions{
+ Format: "",
+ Raw: false,
+ JQ: ctx.JqExpr,
+ Meta: meta,
+ }))
if ctx.outputErr != nil {
return ctx.outputErr
}
return output.PartialFailure(output.ExitAPI)
}
-// emit is the shared stdout envelope emitter; ok sets the envelope's ok field
-// (true for success, false for a partial-failure result). raw=true disables JSON
-// HTML escaping so XML/HTML payloads (e.g. DocxXML bodies) are preserved
-// verbatim; otherwise behavior
-// is identical — content-safety scanning and race-safe first-error capture via
-// outputErrOnce apply in both modes.
-func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw, ok bool) {
- scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut)
- if scanResult.Blocked {
- ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr })
- return
- }
-
- env := output.Envelope{OK: ok, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
- if scanResult.Alert != nil {
- env.ContentSafetyAlert = scanResult.Alert
- }
-
- if ctx.JqExpr != "" {
- filter := output.JqFilter
- if raw {
- filter = output.JqFilterRaw
- }
- if err := filter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
- fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
- ctx.outputErrOnce.Do(func() { ctx.outputErr = err })
- }
- return
- }
-
- if raw {
- enc := json.NewEncoder(ctx.IO().Out)
- enc.SetEscapeHTML(false)
- enc.SetIndent("", " ")
- _ = enc.Encode(env)
- return
- }
- b, _ := json.MarshalIndent(env, "", " ")
- fmt.Fprintln(ctx.IO().Out, string(b))
-}
-
// OutFormat prints output based on --format flag.
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
-// When JqExpr is set, routes through Out() regardless of format.
-// For json/"" and jq paths, Out() handles content safety scanning.
-// For pretty/table/csv/ndjson, scanning is done here and the alert is written to stderr.
+// When JqExpr is set, envelope filtering takes precedence over format.
+// The Emitter handles content safety scanning for every format.
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
- ctx.outFormat(data, meta, prettyFn, false)
+ ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
+ Format: ctx.Format,
+ Raw: false,
+ JQ: ctx.JqExpr,
+ Meta: meta,
+ Pretty: wrapLegacyPrettyRenderer(prettyFn),
+ }))
}
// OutFormatRaw is like OutFormat but with HTML escaping disabled in JSON output.
// Use this when the data contains XML/HTML content that should be preserved as-is.
func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
- ctx.outFormat(data, meta, prettyFn, true)
-}
-
-func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) {
- outFn := ctx.Out
- if raw {
- outFn = ctx.OutRaw
- }
- if ctx.JqExpr != "" {
- outFn(data, meta)
- return
- }
- switch ctx.Format {
- case "pretty":
- scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut)
- if scanResult.Blocked {
- ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr })
- return
- }
- if scanResult.Alert != nil {
- output.WriteAlertWarning(ctx.IO().ErrOut, scanResult.Alert)
- }
- if prettyFn != nil {
- prettyFn(ctx.IO().Out)
- } else {
- outFn(data, meta)
- }
- case "json", "":
- outFn(data, meta)
- default:
- // table, csv, ndjson — pass data directly; FormatValue handles both
- // plain arrays and maps with array fields (e.g. {"members":[…]})
- scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut)
- if scanResult.Blocked {
- ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr })
- return
- }
- if scanResult.Alert != nil {
- output.WriteAlertWarning(ctx.IO().ErrOut, scanResult.Alert)
- }
- format, formatOK := output.ParseFormat(ctx.Format)
- if !formatOK {
- fmt.Fprintf(ctx.IO().ErrOut, "warning: unknown format %q, falling back to json\n", ctx.Format)
- }
- output.FormatValue(ctx.IO().Out, data, format)
- }
+ ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
+ Format: ctx.Format,
+ Raw: true,
+ JQ: ctx.JqExpr,
+ Meta: meta,
+ Pretty: wrapLegacyPrettyRenderer(prettyFn),
+ }))
}
// ── Scope pre-check ──
diff --git a/shortcuts/common/runner_contentsafety_test.go b/shortcuts/common/runner_contentsafety_test.go
index 09d012696..262663aa4 100644
--- a/shortcuts/common/runner_contentsafety_test.go
+++ b/shortcuts/common/runner_contentsafety_test.go
@@ -7,10 +7,12 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"testing"
"github.com/spf13/cobra"
+ "github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -71,7 +73,7 @@ func TestOut_ContentSafetyBlock(t *testing.T) {
extcs.Register(&csTestProvider{alert: alert})
defer extcs.Register(nil)
- rctx, stdout, _ := newCSTestContext(t)
+ rctx, stdout, stderr := newCSTestContext(t)
rctx.Out(map[string]any{"msg": "hello"}, nil)
if stdout.Len() > 0 {
@@ -80,6 +82,16 @@ func TestOut_ContentSafetyBlock(t *testing.T) {
if rctx.outputErr == nil {
t.Error("block mode should set outputErr")
}
+ if stderr.Len() != 0 {
+ t.Fatalf("block mode stderr = %q, want empty", stderr.String())
+ }
+ var safetyErr *errs.ContentSafetyError
+ if !errors.As(rctx.outputErr, &safetyErr) {
+ t.Fatalf("block mode output error = %T, want *errs.ContentSafetyError", rctx.outputErr)
+ }
+ if got := output.ExitCodeOf(rctx.outputErr); got != output.ExitContentSafety {
+ t.Fatalf("block mode exit code = %d, want %d", got, output.ExitContentSafety)
+ }
}
func TestOut_ContentSafetyOff(t *testing.T) {
diff --git a/shortcuts/common/runner_jq_test.go b/shortcuts/common/runner_jq_test.go
index cbc6ceb26..3f20fcbb2 100644
--- a/shortcuts/common/runner_jq_test.go
+++ b/shortcuts/common/runner_jq_test.go
@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"io"
"strings"
"testing"
@@ -14,6 +15,7 @@ import (
lark "github.com/larksuite/oapi-sdk-go/v3"
"github.com/spf13/cobra"
+ "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -102,6 +104,72 @@ func TestRuntimeContext_Out_WithJq_InvalidExpr_WritesStderr(t *testing.T) {
if !strings.Contains(stderr.String(), "error") {
t.Errorf("expected error on stderr for runtime jq error, got: %s", stderr.String())
}
+ problem, ok := errs.ProblemOf(rctx.outputErr)
+ if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("output error problem = %#v, %v; want validation/invalid_argument", problem, ok)
+ }
+ if got := output.ExitCodeOf(rctx.outputErr); got != output.ExitValidation {
+ t.Fatalf("output error exit code = %d, want %d", got, output.ExitValidation)
+ }
+}
+
+type failingRuntimeOutputWriter struct {
+ err error
+}
+
+func (w failingRuntimeOutputWriter) Write([]byte) (int, error) {
+ return 0, w.err
+}
+
+func TestRuntimeContext_OutRaw_PropagatesWriteError(t *testing.T) {
+ rctx, _, stderr := newJqTestContext("", "")
+ sentinel := errors.New("write failed")
+ rctx.Factory.IOStreams.Out = failingRuntimeOutputWriter{err: sentinel}
+
+ rctx.OutRaw(map[string]interface{}{"id": "1"}, nil)
+
+ if !errors.Is(rctx.outputErr, sentinel) {
+ t.Fatalf("OutRaw() output error = %v, want preserved writer cause", rctx.outputErr)
+ }
+ problem, ok := errs.ProblemOf(rctx.outputErr)
+ if !ok || problem.Category != errs.CategoryInternal {
+ t.Fatalf("OutRaw() problem = %#v, %v; want internal typed error", problem, ok)
+ }
+ if got := output.ExitCodeOf(rctx.outputErr); got != output.ExitInternal {
+ t.Fatalf("OutRaw() exit code = %d, want %d", got, output.ExitInternal)
+ }
+ if stderr.Len() != 0 {
+ t.Fatalf("OutRaw() stderr = %q, want empty", stderr.String())
+ }
+}
+
+func TestRunShortcut_OutRawWriteErrorPropagates(t *testing.T) {
+ sentinel := errors.New("write failed")
+ f := newTestFactory()
+ f.IOStreams.Out = failingRuntimeOutputWriter{err: sentinel}
+ s := &Shortcut{
+ Service: "test",
+ Command: "test-shortcut",
+ AuthTypes: []string{"bot"},
+ Execute: func(_ context.Context, rctx *RuntimeContext) error {
+ rctx.OutRaw(map[string]interface{}{"id": "1"}, nil)
+ return nil
+ },
+ }
+ cmd := newTestShortcutCmd(s, f)
+ cmd.Flags().Set("as", "bot")
+
+ err := runShortcut(cmd, f, s, true)
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("runShortcut() error = %v, want preserved writer cause", err)
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryInternal {
+ t.Fatalf("runShortcut() problem = %#v, %v; want internal typed error", problem, ok)
+ }
+ if got := output.ExitCodeOf(err); got != output.ExitInternal {
+ t.Fatalf("runShortcut() exit code = %d, want %d", got, output.ExitInternal)
+ }
}
type testResolvedFileIO struct{}
diff --git a/shortcuts/doc/docs_create_test.go b/shortcuts/doc/docs_create_test.go
index 089a0c3fc..f5002ebb1 100644
--- a/shortcuts/doc/docs_create_test.go
+++ b/shortcuts/doc/docs_create_test.go
@@ -63,7 +63,7 @@ func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
- if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
+ if grant["message"] != "Granted the current CLI user full_access on the new document." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
@@ -173,11 +173,9 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
- if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
- t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
- }
- if !strings.Contains(grant["message"].(string), "retry later") {
- t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
+ wantMessage := "Resource was created, but granting current user full_access failed: no permission. You can retry later or continue using bot identity."
+ if grant["message"] != wantMessage {
+ t.Fatalf("permission_grant.message = %q, want %q", grant["message"], wantMessage)
}
if !strings.Contains(stderr.String(), "auto-grant failed") {
t.Fatalf("stderr missing auto-grant failed warning; got:\n%s", stderr.String())
diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go
index 96a9ed3a2..e5c5e3672 100644
--- a/shortcuts/doc/docs_create_v2.go
+++ b/shortcuts/doc/docs_create_v2.go
@@ -59,7 +59,7 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
}
desc := "OpenAPI: create document"
if runtime.IsBot() {
- desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document."
+ desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new document."
}
return common.NewDryRunAPI().
POST("/open-apis/docs_ai/v1/documents").
diff --git a/shortcuts/doc/docs_fetch_im_markdown.go b/shortcuts/doc/docs_fetch_im_markdown.go
index 13b37002f..7c9412702 100644
--- a/shortcuts/doc/docs_fetch_im_markdown.go
+++ b/shortcuts/doc/docs_fetch_im_markdown.go
@@ -73,10 +73,10 @@ func init() {
registerIMMarkdownHandler("time", handleIMMarkdownDiscard)
registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode)
registerIMMarkdownHandler("sheet", handleIMMarkdownSheet)
- registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id"))
- registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
- registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
- registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
+ registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("Task", "task-id", "guid", "token", "id"))
+ registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("Chat card", "chat-id", "chat_id", "id"))
+ registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("Base"))
+ registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("Base"))
registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR"))
registerIMMarkdownHandler("poll", handleIMMarkdownDiscard)
registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard)
diff --git a/shortcuts/doc/docs_fetch_im_markdown_test.go b/shortcuts/doc/docs_fetch_im_markdown_test.go
index 971b4878d..262c48ed3 100644
--- a/shortcuts/doc/docs_fetch_im_markdown_test.go
+++ b/shortcuts/doc/docs_fetch_im_markdown_test.go
@@ -975,8 +975,8 @@ func TestConvertToIMMarkdownDocumentExpectedTagsAndEscaping(t *testing.T) {
"````Go\nfmt.Println(\"hi\")\n```\n````",
"`` `edge` `` $E=mc^2$ --- ![A \\[img\\]](https://example.com/i%281%29.png)",
"``report`v1`.pdf``",
- "`任务``群聊卡片`",
- "`多维表格``多维表格``OKR`",
+ "`Task``Chat card`",
+ "`Base``Base``OKR`",
}, "\n")
if got := convertToIMMarkdown(input, imCtx); got != want {
diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go
index 21cc226bf..f8b43812e 100644
--- a/shortcuts/doc/docs_fetch_v2.go
+++ b/shortcuts/doc/docs_fetch_v2.go
@@ -26,7 +26,7 @@ func v2FetchFlags() []common.Flag {
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
{Name: "end-block-id", Desc: "range end block id; -1 means through document end"},
- {Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|缺陷"},
+ {Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|error"},
{Name: "context-before", Desc: "range/keyword/section context: sibling blocks before selected top-level blocks", Type: "int", Default: "0"},
{Name: "context-after", Desc: "range/keyword/section context: sibling blocks after selected top-level blocks", Type: "int", Default: "0"},
{Name: "max-depth", Desc: "outline heading level cap; other scopes subtree depth where -1 is unlimited and 0 is block only", Type: "int", Default: "-1"},
diff --git a/shortcuts/doc/docs_fetch_v2_test.go b/shortcuts/doc/docs_fetch_v2_test.go
index d74e39926..68d2750ce 100644
--- a/shortcuts/doc/docs_fetch_v2_test.go
+++ b/shortcuts/doc/docs_fetch_v2_test.go
@@ -443,7 +443,7 @@ func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
name: "keyword with keyword",
setFlags: map[string]string{
"scope": "keyword",
- "keyword": "bug|缺陷",
+ "keyword": "bug|error",
},
},
{
diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go
index f610168c8..755b35c70 100644
--- a/shortcuts/doc/docs_update_v2.go
+++ b/shortcuts/doc/docs_update_v2.go
@@ -24,7 +24,7 @@ var validCommandsV2 = map[string]bool{
"append": true,
}
-const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
+const docsReferenceMapFlagDesc = "Structured `reference_map` JSON object; must be used with `--content`. Prefer embedding structure directly in the document body for ordinary writes; use `--reference-map` primarily to preserve or replay an existing `document.reference_map`. Accepts inline JSON, `@reference-map.json` (relative path), or `-` to read from stdin."
const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc
diff --git a/shortcuts/doc/html5_block_resources_test.go b/shortcuts/doc/html5_block_resources_test.go
index 413a0e8e9..d6e252ac1 100644
--- a/shortcuts/doc/html5_block_resources_test.go
+++ b/shortcuts/doc/html5_block_resources_test.go
@@ -19,6 +19,8 @@ import (
)
func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
+ wantDesc := "Structured `reference_map` JSON object; must be used with `--content`. Prefer embedding structure directly in the document body for ordinary writes; use `--reference-map` primarily to preserve or replay an existing `document.reference_map`. Accepts inline JSON, `@reference-map.json` (relative path), or `-` to read from stdin."
+
for name, flags := range map[string][]common.Flag{
"create": v2CreateFlags(),
"update": v2UpdateFlags(),
@@ -34,8 +36,8 @@ func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) {
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
}
- if !strings.Contains(flag.Desc, "@reference-map.json") {
- t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
+ if flag.Desc != wantDesc {
+ t.Fatalf("reference-map help = %q, want English description %q", flag.Desc, wantDesc)
}
})
}
diff --git a/shortcuts/drive/drive_add_comment.go b/shortcuts/drive/drive_add_comment.go
index 322df1941..86837f420 100644
--- a/shortcuts/drive/drive_add_comment.go
+++ b/shortcuts/drive/drive_add_comment.go
@@ -772,7 +772,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
var inputs []commentReplyElementInput
if err := json.Unmarshal([]byte(raw), &inputs); err != nil {
- return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err).WithParam("--content")
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"Example text\"}]'", err).WithParam("--content")
}
if len(inputs) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must contain at least one reply element").WithParam("--content")
diff --git a/shortcuts/drive/drive_create_folder.go b/shortcuts/drive/drive_create_folder.go
index 38507d491..4cdeec577 100644
--- a/shortcuts/drive/drive_create_folder.go
+++ b/shortcuts/drive/drive_create_folder.go
@@ -59,7 +59,7 @@ var DriveCreateFolder = common.Shortcut{
Desc("[1] Create folder").
Body(spec.RequestBody())
if runtime.IsBot() {
- dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new folder.")
+ dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new folder.")
}
return dry
},
diff --git a/shortcuts/drive/drive_create_folder_test.go b/shortcuts/drive/drive_create_folder_test.go
index 5d0e8980d..974f2c573 100644
--- a/shortcuts/drive/drive_create_folder_test.go
+++ b/shortcuts/drive/drive_create_folder_test.go
@@ -90,6 +90,7 @@ func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
+ Desc string `json:"desc"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
@@ -108,6 +109,10 @@ func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
if got.API[0].Body["folder_token"] != "fld_parent" {
t.Fatalf("folder_token = %#v, want %q", got.API[0].Body["folder_token"], "fld_parent")
}
+ wantDesc := "After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new folder."
+ if got.API[0].Desc != wantDesc {
+ t.Fatalf("desc = %q, want %q", got.API[0].Desc, wantDesc)
+ }
}
func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
@@ -178,7 +183,7 @@ func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
- if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new folder." {
+ if grant["message"] != "Granted the current CLI user full_access on the new folder." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go
index c34a42414..36c6e7326 100644
--- a/shortcuts/drive/drive_import.go
+++ b/shortcuts/drive/drive_import.go
@@ -114,7 +114,7 @@ func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.Dr
Desc("[3] Poll import task result").
Set("ticket", "")
if runtime.IsBot() {
- dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
+ dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access on it.")
}
return dry
diff --git a/shortcuts/drive/drive_import_test.go b/shortcuts/drive/drive_import_test.go
index af13fc9c2..4e7160793 100644
--- a/shortcuts/drive/drive_import_test.go
+++ b/shortcuts/drive/drive_import_test.go
@@ -95,7 +95,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
t.Fatalf("set --folder-token: %v", err)
}
- runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
+ runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
dry := DriveImport.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
@@ -108,6 +108,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
var got struct {
API []struct {
+ Desc string `json:"desc"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
@@ -117,6 +118,10 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
if len(got.API) != 4 {
t.Fatalf("expected 4 API calls, got %d", len(got.API))
}
+ wantDesc := "After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access on it."
+ if got.API[len(got.API)-1].Desc != wantDesc {
+ t.Fatalf("desc = %q, want %q", got.API[len(got.API)-1].Desc, wantDesc)
+ }
if got.API[0].Body != nil {
t.Fatalf("wiki probe should not have a request body, got %#v", got.API[0].Body)
diff --git a/shortcuts/drive/drive_io_test.go b/shortcuts/drive/drive_io_test.go
index 6391cbf09..39a1a929f 100644
--- a/shortcuts/drive/drive_io_test.go
+++ b/shortcuts/drive/drive_io_test.go
@@ -1088,7 +1088,7 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
t.Fatalf("set --wiki-token: %v", err)
}
- runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
+ runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
dry := DriveUpload.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
@@ -1100,7 +1100,8 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
}
var got struct {
- API []struct {
+ PostUploadNote string `json:"post_upload_note"`
+ API []struct {
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
@@ -1123,6 +1124,10 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
if got.API[1].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
}
+ wantPostUploadNote := "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new file."
+ if got.PostUploadNote != wantPostUploadNote {
+ t.Fatalf("post_upload_note = %q, want %q", got.PostUploadNote, wantPostUploadNote)
+ }
}
func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
diff --git a/shortcuts/drive/drive_permission_grant_test.go b/shortcuts/drive/drive_permission_grant_test.go
index 74e4ef016..122b5aa10 100644
--- a/shortcuts/drive/drive_permission_grant_test.go
+++ b/shortcuts/drive/drive_permission_grant_test.go
@@ -65,7 +65,7 @@ func TestDriveUploadBotAutoGrantSuccess(t *testing.T) {
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
- if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new file." {
+ if grant["message"] != "Granted the current CLI user full_access on the new file." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go
index 5d2763b45..a02941fcc 100644
--- a/shortcuts/drive/drive_upload.go
+++ b/shortcuts/drive/drive_upload.go
@@ -103,7 +103,7 @@ var DriveUpload = common.Shortcut{
"Omit both --folder-token and --wiki-token to upload into the caller's Drive root folder.",
"Use --wiki-token to upload under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
"Pass --file-token to overwrite an existing Drive file in place; the shortcut forwards file_token to the upload API.",
- "In bot mode, automatic full_access (可管理权限) grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.",
+ "In bot mode, automatic full_access grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveUploadSpec(runtime, newDriveUploadSpec(runtime))
@@ -137,7 +137,7 @@ var DriveUpload = common.Shortcut{
"with_url": true,
})
if runtime.IsBot() && !isOverwrite {
- d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
+ d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new file.")
}
return d
},
diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go
index fc7a57d61..5f5071d1a 100644
--- a/shortcuts/drive/shortcuts_test.go
+++ b/shortcuts/drive/shortcuts_test.go
@@ -5,6 +5,7 @@ package drive
import (
"reflect"
+ "strings"
"testing"
)
@@ -71,3 +72,18 @@ func TestDriveSearchSupportsUserAndBotIdentity(t *testing.T) {
t.Fatalf("DriveSearch.AuthTypes = %v, want %v", DriveSearch.AuthTypes, want)
}
}
+
+func TestDriveUploadHelpTipUsesEnglishPermissionName(t *testing.T) {
+ t.Parallel()
+
+ want := "In bot mode, automatic full_access grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions."
+ for _, tip := range DriveUpload.Tips {
+ if strings.Contains(tip, "automatic full_access") {
+ if tip != want {
+ t.Fatalf("DriveUpload full_access tip = %q, want %q", tip, want)
+ }
+ return
+ }
+ }
+ t.Fatal("DriveUpload full_access help tip not found")
+}
diff --git a/shortcuts/im/im_flag_list.go b/shortcuts/im/im_flag_list.go
index d4761e124..9a0c52970 100644
--- a/shortcuts/im/im_flag_list.go
+++ b/shortcuts/im/im_flag_list.go
@@ -27,8 +27,8 @@ var ImFlagList = common.Shortcut{
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
{Name: "page-token", Desc: "pagination token for next page"},
- {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"},
- {Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"},
+ {Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"},
+ {Name: "page-limit", Type: "int", Default: "20", Desc: "max pages with --page-all (default 20; configurable range 1-1000)"},
{Name: "enrich-feed-thread", Type: "bool", Default: "true", Desc: "fetch message content for feed-type thread entries (default true; may call messages/mget and require im:message.group_msg:get_as_user/im:message.p2p_msg:get_as_user; use --enrich-feed-thread=false to avoid extra scopes)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -278,6 +278,10 @@ func executeListAllPages(rt *common.RuntimeContext) error {
fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n")
break
}
+ if page+1 >= maxPages {
+ fmt.Fprintf(rt.IO().ErrOut, "[pagination] reached page limit (%d) while has_more=true; result is incomplete. Increase --page-limit up to 1000 or resume with the page_token returned in stdout.\n", maxPages)
+ break
+ }
prevPageToken = lastPageToken
}
diff --git a/shortcuts/im/im_flag_test.go b/shortcuts/im/im_flag_test.go
index 8a1a31e6b..d370c4cde 100644
--- a/shortcuts/im/im_flag_test.go
+++ b/shortcuts/im/im_flag_test.go
@@ -1536,6 +1536,9 @@ func TestExecuteListAllPages(t *testing.T) {
if callCount != 2 {
t.Fatalf("expected 2 API calls, got %d", callCount)
}
+ if stderr := rt.IO().ErrOut.(*bytes.Buffer).String(); strings.Contains(stderr, "reached page limit") {
+ t.Fatalf("natural pagination completion must not warn about a page limit, got %q", stderr)
+ }
}
func TestExecuteListAllPages_EnrichFeedThread(t *testing.T) {
@@ -1625,6 +1628,73 @@ func TestExecuteListAllPages_PageLimit(t *testing.T) {
if callCount != 3 {
t.Fatalf("expected 3 API calls (page limit), got %d", callCount)
}
+ stderr := rt.IO().ErrOut.(*bytes.Buffer).String()
+ for _, want := range []string{"reached page limit (3)", "has_more=true", "result is incomplete", "up to 1000", "page_token returned in stdout"} {
+ if !strings.Contains(stderr, want) {
+ t.Fatalf("stderr = %q, want %q", stderr, want)
+ }
+ }
+ if strings.Contains(stderr, "token_3") {
+ t.Fatalf("stderr must not expose the continuation token, got %q", stderr)
+ }
+
+ var envelope map[string]any
+ if err := json.Unmarshal(rt.IO().Out.(*bytes.Buffer).Bytes(), &envelope); err != nil {
+ t.Fatalf("decode stdout: %v", err)
+ }
+ data, _ := envelope["data"].(map[string]any)
+ if hasMore, _ := data["has_more"].(bool); !hasMore {
+ t.Fatalf("has_more = %#v, want true for an incomplete result", data["has_more"])
+ }
+ if pageToken, _ := data["page_token"].(string); pageToken != "token_3" {
+ t.Fatalf("page_token = %q, want token_3", pageToken)
+ }
+ if _, exists := data["truncated"]; exists {
+ t.Fatalf("output schema must remain unchanged; unexpected truncated field in %#v", data)
+ }
+}
+
+func TestExecuteListAllPages_RepeatedTokenDoesNotReportPageLimit(t *testing.T) {
+ callCount := 0
+ rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
+ if strings.Contains(req.URL.Path, "/open-apis/im/v1/flags") {
+ callCount++
+ return shortcutJSONResponse(200, map[string]any{
+ "code": 0,
+ "data": map[string]any{
+ "flag_items": []any{},
+ "delete_flag_items": []any{},
+ "messages": []any{},
+ "has_more": true,
+ "page_token": "same_token",
+ },
+ }), nil
+ }
+ return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
+ }))
+
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().Int("page-size", 50, "")
+ cmd.Flags().Int("page-limit", 10, "")
+ cmd.Flags().Bool("enrich-feed-thread", false, "")
+ if err := cmd.ParseFlags(nil); err != nil {
+ t.Fatalf("ParseFlags() error = %v", err)
+ }
+ setRuntimeField(t, rt, "Cmd", cmd)
+
+ if err := executeListAllPages(rt); err != nil {
+ t.Fatalf("executeListAllPages() error = %v", err)
+ }
+ if callCount != 2 {
+ t.Fatalf("API calls = %d, want 2 before repeated-token stop", callCount)
+ }
+ stderr := rt.IO().ErrOut.(*bytes.Buffer).String()
+ if !strings.Contains(stderr, "page_token did not change") {
+ t.Fatalf("stderr = %q, want non-advancing token warning", stderr)
+ }
+ if strings.Contains(stderr, "reached page limit") {
+ t.Fatalf("stderr = %q, repeated token must not be reported as a page-limit stop", stderr)
+ }
}
func TestExecuteListAllPages_APIError(t *testing.T) {
diff --git a/shortcuts/okr/okr_batch_create.go b/shortcuts/okr/okr_batch_create.go
index 61df08632..8c5c5c0bb 100644
--- a/shortcuts/okr/okr_batch_create.go
+++ b/shortcuts/okr/okr_batch_create.go
@@ -24,9 +24,12 @@ type batchCreateKR struct {
// batchCreateObjective represents an objective in the batch create input.
type batchCreateObjective struct {
- Text string `json:"text"`
- Mention []string `json:"mention,omitempty"`
- KRs []batchCreateKR `json:"krs,omitempty"`
+ Text string `json:"text"`
+ Mention []string `json:"mention,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ NotesMention []string `json:"notes_mention,omitempty"`
+ CategoryID string `json:"category_id,omitempty"`
+ KRs []batchCreateKR `json:"krs,omitempty"`
}
// createdObjective tracks a created objective and its KR IDs for output.
@@ -49,6 +52,25 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
if strings.TrimSpace(obj.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].text is required and cannot be empty", i).WithParam("--input")
}
+ if obj.Notes != "" && strings.TrimSpace(obj.Notes) == "" {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes cannot be blank when provided", i).WithParam("--input")
+ }
+ if obj.Notes == "" && len(obj.NotesMention) > 0 {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes is required when notes_mention is provided", i).WithParam("--input")
+ }
+ for j, mention := range obj.NotesMention {
+ if strings.TrimSpace(mention) == "" {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes_mention[%d] cannot be empty", i, j).WithParam("--input")
+ }
+ }
+ if obj.CategoryID != "" {
+ if strings.TrimSpace(obj.CategoryID) == "" {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].category_id cannot be blank when provided", i).WithParam("--input")
+ }
+ if id, err := strconv.ParseInt(obj.CategoryID, 10, 64); err != nil || id <= 0 {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].category_id must be a positive int64", i).WithParam("--input")
+ }
+ }
for j, kr := range obj.KRs {
if strings.TrimSpace(kr.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].krs[%d].text is required and cannot be empty", i, j).WithParam("--input")
@@ -59,11 +81,24 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
}
// createObjective calls the API to create an objective.
-func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
+func effectiveBatchObjectiveCategoryID(defaultCategoryID string, obj batchCreateObjective) string {
+ if obj.CategoryID != "" {
+ return obj.CategoryID
+ }
+ return defaultCategoryID
+}
+
+func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType, defaultCategoryID string, obj batchCreateObjective) (string, error) {
content := BuildContentBlock(obj.Text, obj.Mention)
body := map[string]interface{}{
"content": content,
}
+ if obj.Notes != "" {
+ body["notes"] = BuildContentBlock(obj.Notes, obj.NotesMention)
+ }
+ if categoryID := effectiveBatchObjectiveCategoryID(defaultCategoryID, obj); categoryID != "" {
+ body["category_id"] = categoryID
+ }
queryParams := map[string]interface{}{
"cycle_id": cycleID,
"user_id_type": userIDType,
@@ -156,6 +191,7 @@ var OKRBatchCreate = common.Shortcut{
Flags: []common.Flag{
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
{Name: "input", Desc: "JSON array of objectives: [{\"text\":\"...\",\"mention\":[\"...\"],\"krs\":[{\"text\":\"...\",\"mention\":[\"...\"]}]}]", Input: []string{common.File, common.Stdin}, Required: true},
+ {Name: "category-id", Desc: "default objective category ID for objectives that do not set category_id"},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -171,6 +207,15 @@ var OKRBatchCreate = common.Shortcut{
if _, err := parseBatchCreateInput(input); err != nil {
return err
}
+ categoryID := runtime.Str("category-id")
+ if categoryID != "" {
+ if err := common.RejectDangerousCharsTyped("--category-id", categoryID); err != nil {
+ return err
+ }
+ if id, err := strconv.ParseInt(categoryID, 10, 64); err != nil || id <= 0 {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id must be a positive int64").WithParam("--category-id")
+ }
+ }
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
@@ -182,6 +227,7 @@ var OKRBatchCreate = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
cycleID := runtime.Str("cycle-id")
userIDType := runtime.Str("user-id-type")
+ defaultCategoryID := runtime.Str("category-id")
objectives, _ := parseBatchCreateInput(runtime.Str("input"))
apis := common.NewDryRunAPI()
@@ -192,6 +238,12 @@ var OKRBatchCreate = common.Shortcut{
objBody := map[string]interface{}{
"content": objContent,
}
+ if obj.Notes != "" {
+ objBody["notes"] = BuildContentBlock(obj.Notes, obj.NotesMention)
+ }
+ if categoryID := effectiveBatchObjectiveCategoryID(defaultCategoryID, obj); categoryID != "" {
+ objBody["category_id"] = categoryID
+ }
objParams := map[string]interface{}{
"cycle_id": cycleID,
"user_id_type": userIDType,
@@ -227,6 +279,7 @@ var OKRBatchCreate = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
userIDType := runtime.Str("user-id-type")
+ defaultCategoryID := runtime.Str("category-id")
objectives, err := parseBatchCreateInput(runtime.Str("input"))
if err != nil {
return err
@@ -241,7 +294,7 @@ var OKRBatchCreate = common.Shortcut{
}
// Create objective
- objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, obj)
+ objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, defaultCategoryID, obj)
if err != nil {
if len(created) == 0 {
return err
diff --git a/shortcuts/okr/okr_batch_create_test.go b/shortcuts/okr/okr_batch_create_test.go
index 3efa2e0ca..ecbdf0770 100644
--- a/shortcuts/okr/okr_batch_create_test.go
+++ b/shortcuts/okr/okr_batch_create_test.go
@@ -6,6 +6,8 @@ package okr
import (
"bytes"
"errors"
+ "io"
+ "net/http"
"strings"
"testing"
@@ -14,6 +16,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
+ "github.com/tidwall/gjson"
)
func batchCreateTestConfig(t *testing.T) *core.CliConfig {
@@ -43,6 +46,15 @@ const validBatchCreateInput = `[
{"text":"Objective 2","krs":[{"text":"KR 2.1"},{"text":"KR 2.2"}]}
]`
+const validBatchCreateInputWithNotes = `[
+ {"text":"Objective 1","notes":"Objective notes","notes_mention":["ou_note"],"krs":[{"text":"KR 1.1"}]}
+]`
+
+const validBatchCreateInputWithCategory = `[
+ {"text":"Objective 1","category_id":"222","krs":[{"text":"KR 1.1"}]},
+ {"text":"Objective 2","krs":[]}
+]`
+
// --- Validate tests ---
func TestBatchCreateValidate_MissingCycleID(t *testing.T) {
@@ -197,6 +209,46 @@ func TestBatchCreateValidate_EmptyKRText(t *testing.T) {
}
}
+func TestBatchCreateValidate_EmptyObjectiveNotesMention(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
+ err := runBatchCreateShortcut(t, f, stdout, []string{
+ "+batch-create",
+ "--cycle-id", "123",
+ "--input", `[{"text":"Obj 1","notes":"Notes","notes_mention":[" "]}]`,
+ })
+ if err == nil {
+ t.Fatal("expected error for empty objective notes mention")
+ }
+ validationErr, ok := err.(*errs.ValidationError)
+ if !ok || validationErr.Param != "--input" {
+ t.Fatalf("expected param --input, got: %v", err)
+ }
+ if !strings.Contains(err.Error(), "objective[0].notes_mention[0]") {
+ t.Fatalf("expected error to mention objective[0].notes_mention[0], got: %v", err)
+ }
+}
+
+func TestBatchCreateValidate_NotesMentionRequiresNotes(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
+ err := runBatchCreateShortcut(t, f, stdout, []string{
+ "+batch-create",
+ "--cycle-id", "123",
+ "--input", `[{"text":"Obj 1","notes_mention":["ou_note"]}]`,
+ })
+ if err == nil {
+ t.Fatal("expected error for notes_mention without notes")
+ }
+ validationErr, ok := err.(*errs.ValidationError)
+ if !ok || validationErr.Param != "--input" {
+ t.Fatalf("expected param --input, got: %v", err)
+ }
+ if !strings.Contains(err.Error(), "objective[0].notes is required when notes_mention is provided") {
+ t.Fatalf("expected error to mention missing notes, got: %v", err)
+ }
+}
+
func TestBatchCreateValidate_InvalidUserIDType(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
@@ -323,6 +375,49 @@ func TestBatchCreateDryRun(t *testing.T) {
}
}
+func TestBatchCreateDryRun_WithObjectiveNotes(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
+ err := runBatchCreateShortcut(t, f, stdout, []string{
+ "+batch-create",
+ "--cycle-id", "123",
+ "--input", validBatchCreateInputWithNotes,
+ "--dry-run",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ output := stdout.String()
+ if !strings.Contains(output, "Objective notes") {
+ t.Fatalf("dry-run output should contain objective notes, got: %s", output)
+ }
+ if !strings.Contains(output, "ou_note") {
+ t.Fatalf("dry-run output should contain objective notes mention, got: %s", output)
+ }
+}
+
+func TestBatchCreateDryRun_WithCategoryID(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
+ err := runBatchCreateShortcut(t, f, stdout, []string{
+ "+batch-create",
+ "--cycle-id", "123",
+ "--category-id", "111",
+ "--input", validBatchCreateInputWithCategory,
+ "--dry-run",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ output := stdout.String()
+ if got := gjson.Get(output, "data.api.0.body.category_id").String(); got != "222" {
+ t.Fatalf("first objective category_id = %q, want per-objective override 222; output: %s", got, output)
+ }
+ if got := gjson.Get(output, "data.api.2.body.category_id").String(); got != "111" {
+ t.Fatalf("second objective category_id = %q, want default 111; output: %s", got, output)
+ }
+}
+
// --- Execute tests ---
func TestBatchCreateExecute_Success(t *testing.T) {
@@ -380,6 +475,94 @@ func TestBatchCreateExecute_Success(t *testing.T) {
}
}
+func TestBatchCreateExecute_ObjectiveWithNotes(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
+ var objectiveBody []byte
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/okr/v2/cycles/123/objectives",
+ OnMatch: func(req *http.Request) {
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ t.Fatalf("read objective request body: %v", err)
+ }
+ objectiveBody = body
+ },
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "objective_id": "100",
+ },
+ },
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/okr/v2/objectives/100/key_results",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "key_result_id": "200",
+ },
+ },
+ })
+ err := runBatchCreateShortcut(t, f, stdout, []string{
+ "+batch-create",
+ "--cycle-id", "123",
+ "--input", validBatchCreateInputWithNotes,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.0.text_run.text").Exists() {
+ t.Fatalf("objective request body missing notes: %s", string(objectiveBody))
+ }
+ if got := gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.0.text_run.text").String(); got != "Objective notes" {
+ t.Fatalf("notes text = %q, want Objective notes; body: %s", got, string(objectiveBody))
+ }
+ if got := gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_note" {
+ t.Fatalf("notes mention = %q, want ou_note; body: %s", got, string(objectiveBody))
+ }
+}
+
+func TestBatchCreateExecute_ObjectiveWithCategoryID(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
+ var objectiveBody []byte
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/okr/v2/cycles/123/objectives",
+ OnMatch: func(req *http.Request) {
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ t.Fatalf("read objective request body: %v", err)
+ }
+ objectiveBody = body
+ },
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "objective_id": "100",
+ },
+ },
+ })
+ err := runBatchCreateShortcut(t, f, stdout, []string{
+ "+batch-create",
+ "--cycle-id", "123",
+ "--category-id", "7249339036661170180",
+ "--input", `[{"text":"Obj 1"}]`,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got := gjson.GetBytes(objectiveBody, "category_id").String(); got != "7249339036661170180" {
+ t.Fatalf("category_id = %q, want 7249339036661170180; body: %s", got, string(objectiveBody))
+ }
+}
+
func TestBatchCreateExecute_APIErrorOnObjective(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
diff --git a/shortcuts/okr/okr_create.go b/shortcuts/okr/okr_create.go
new file mode 100644
index 000000000..3d674723f
--- /dev/null
+++ b/shortcuts/okr/okr_create.go
@@ -0,0 +1,394 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package okr
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+// createParams holds the parsed parameters for single-object create operations.
+type createParams struct {
+ Level string
+ CycleID string
+ ObjectiveID string
+ Style string
+ Content *ContentBlock
+ Notes *ContentBlock
+ CategoryID string
+ UserIDType string
+}
+
+type createContentMultipleJSONValuesError struct{}
+
+func (createContentMultipleJSONValuesError) Error() string {
+ return "multiple JSON values"
+}
+
+var errCreateContentMultipleJSONValues createContentMultipleJSONValuesError
+
+type okrCreateRequestBody struct {
+ Content *ContentBlock `json:"content"`
+ Notes *ContentBlock `json:"notes,omitempty"`
+ CategoryID string `json:"category_id,omitempty"`
+}
+
+type okrCreateObjectiveQuery struct {
+ CycleID string
+ UserIDType string
+}
+
+type okrCreateKeyResultQuery struct {
+ ObjectiveID string
+ UserIDType string
+}
+
+type okrCreateObjectiveResponse struct {
+ ObjectiveID string
+}
+
+type okrCreateKeyResultResponse struct {
+ KeyResultID string
+}
+
+type okrCreateObjectiveOutput struct {
+ Level string `json:"level"`
+ ObjectiveID string `json:"objective_id"`
+}
+
+type okrCreateKeyResultOutput struct {
+ Level string `json:"level"`
+ ObjectiveID string `json:"objective_id"`
+ KeyResultID string `json:"key_result_id"`
+}
+
+func decodeCreateContentStrict(inputStr string, target interface{}, param, message string) error {
+ dec := json.NewDecoder(bytes.NewReader([]byte(inputStr)))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(target); err != nil {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, message, err).
+ WithParam(param).
+ WithCause(err)
+ }
+ var trailing interface{}
+ if err := dec.Decode(&trailing); !errors.Is(err, io.EOF) {
+ if err == nil {
+ err = errCreateContentMultipleJSONValues
+ }
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, message, err).
+ WithParam(param).
+ WithCause(err)
+ }
+ return nil
+}
+
+func parseCreateContentValue(inputStr, param, style string) (*ContentBlock, error) {
+ if style == "simple" {
+ var sp SemiPlainContent
+ if err := decodeCreateContentStrict(inputStr, &sp, param, fmt.Sprintf("%s must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %%s", param)); err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(sp.Text) == "" {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s text is required and cannot be empty", param).WithParam(param)
+ }
+ for i, mention := range sp.Mention {
+ if strings.TrimSpace(mention) == "" {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s mention[%d] cannot be empty", param, i).WithParam(param)
+ }
+ }
+ if len(sp.Docs) > 0 || len(sp.Images) > 0 {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s docs and images are not supported in simple style input; use richtext style or remove these fields", param).WithParam(param)
+ }
+ return sp.ToContentBlock(), nil
+ }
+
+ var cb ContentBlock
+ if err := decodeCreateContentStrict(inputStr, &cb, param, fmt.Sprintf("%s must be valid ContentBlock JSON: %%s", param)); err != nil {
+ return nil, err
+ }
+ if len(cb.Blocks) == 0 {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must contain at least one block", param).WithParam(param)
+ }
+
+ hasNonEmptyParagraph := false
+ for _, block := range cb.Blocks {
+ if block.Paragraph != nil && len(block.Paragraph.Elements) > 0 {
+ hasNonEmptyParagraph = true
+ break
+ }
+ if block.Gallery != nil && len(block.Gallery.Images) > 0 {
+ hasNonEmptyParagraph = true
+ break
+ }
+ }
+ if !hasNonEmptyParagraph {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be empty", param).WithParam(param)
+ }
+ return &cb, nil
+}
+
+func projectCreateRequestBody(body okrCreateRequestBody) map[string]interface{} {
+ result := map[string]interface{}{
+ "content": body.Content,
+ }
+ if body.Notes != nil {
+ result["notes"] = body.Notes
+ }
+ if body.CategoryID != "" {
+ result["category_id"] = body.CategoryID
+ }
+ return result
+}
+
+func projectCreateObjectiveQuery(query okrCreateObjectiveQuery) map[string]interface{} {
+ return map[string]interface{}{
+ "cycle_id": query.CycleID,
+ "user_id_type": query.UserIDType,
+ }
+}
+
+func projectCreateKeyResultQuery(query okrCreateKeyResultQuery) map[string]interface{} {
+ return map[string]interface{}{
+ "objective_id": query.ObjectiveID,
+ "user_id_type": query.UserIDType,
+ }
+}
+
+func projectCreateObjectiveResponse(data map[string]interface{}) (*okrCreateObjectiveResponse, error) {
+ objectiveID, ok := data["objective_id"].(string)
+ if !ok || objectiveID == "" {
+ return nil, errs.NewInternalError(errs.SubtypeUnknown, "create objective response missing objective_id")
+ }
+ return &okrCreateObjectiveResponse{ObjectiveID: objectiveID}, nil
+}
+
+func projectCreateKeyResultResponse(data map[string]interface{}) (*okrCreateKeyResultResponse, error) {
+ keyResultID, ok := data["key_result_id"].(string)
+ if !ok || keyResultID == "" {
+ return nil, errs.NewInternalError(errs.SubtypeUnknown, "create key result response missing key_result_id")
+ }
+ return &okrCreateKeyResultResponse{KeyResultID: keyResultID}, nil
+}
+
+// parseCreateParams parses and validates flags from runtime into request-ready parameters.
+func parseCreateParams(runtime *common.RuntimeContext) (*createParams, error) {
+ p := &createParams{
+ Level: runtime.Str("level"),
+ CycleID: runtime.Str("cycle-id"),
+ ObjectiveID: runtime.Str("objective-id"),
+ Style: runtime.Str("style"),
+ CategoryID: runtime.Str("category-id"),
+ UserIDType: runtime.Str("user-id-type"),
+ }
+
+ contentStr := runtime.Str("content")
+ if contentStr == "" {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
+ }
+ if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
+ return nil, err
+ }
+ content, err := parseCreateContentValue(contentStr, "--content", p.Style)
+ if err != nil {
+ return nil, err
+ }
+ p.Content = content
+
+ if notesStr := runtime.Str("notes"); notesStr != "" {
+ if p.Level != "objective" {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported when --level=objective").WithParam("--notes")
+ }
+ if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
+ return nil, err
+ }
+ notes, err := parseCreateContentValue(notesStr, "--notes", p.Style)
+ if err != nil {
+ return nil, err
+ }
+ p.Notes = notes
+ }
+ if p.CategoryID != "" {
+ if p.Level != "objective" {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id is only supported when --level=objective").WithParam("--category-id")
+ }
+ if err := common.RejectDangerousCharsTyped("--category-id", p.CategoryID); err != nil {
+ return nil, err
+ }
+ if id, err := strconv.ParseInt(p.CategoryID, 10, 64); err != nil || id <= 0 {
+ return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id must be a positive int64").WithParam("--category-id")
+ }
+ }
+ return p, nil
+}
+
+// OKRCreate creates a single objective or key result.
+var OKRCreate = common.Shortcut{
+ Service: "okr",
+ Command: "+create",
+ Description: "Create a single OKR objective or key result",
+ Risk: "write",
+ Scopes: []string{"okr:okr.content:writeonly"},
+ AuthTypes: []string{"user", "bot"},
+ HasFormat: true,
+ Flags: []common.Flag{
+ {Name: "level", Desc: "create level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
+ {Name: "cycle-id", Desc: "OKR cycle ID (required for level=objective)"},
+ {Name: "objective-id", Desc: "objective ID (required for level=key-result)"},
+ {Name: "style", Default: "simple", Desc: "input style for content: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
+ {Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Required: true, Input: []string{common.File, common.Stdin}},
+ {Name: "notes", Desc: "objective notes: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
+ {Name: "category-id", Desc: "objective category ID; use only when classification is requested or the tenant requires categories"},
+ {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
+ },
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ level := runtime.Str("level")
+ if level != "objective" && level != "key-result" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
+ }
+
+ style := runtime.Str("style")
+ if style != "simple" && style != "richtext" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
+ }
+
+ idType := runtime.Str("user-id-type")
+ if idType != "open_id" && idType != "union_id" && idType != "user_id" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
+ }
+
+ switch level {
+ case "objective":
+ if runtime.Str("objective-id") != "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id cannot be used when --level=objective").WithParam("--objective-id")
+ }
+ cycleID := runtime.Str("cycle-id")
+ if cycleID == "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required when --level=objective").WithParam("--cycle-id")
+ }
+ if err := common.RejectDangerousCharsTyped("--cycle-id", cycleID); err != nil {
+ return err
+ }
+ if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
+ }
+ case "key-result":
+ if runtime.Str("cycle-id") != "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id cannot be used when --level=key-result").WithParam("--cycle-id")
+ }
+ objectiveID := runtime.Str("objective-id")
+ if objectiveID == "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
+ }
+ if err := common.RejectDangerousCharsTyped("--objective-id", objectiveID); err != nil {
+ return err
+ }
+ if id, err := strconv.ParseInt(objectiveID, 10, 64); err != nil || id <= 0 {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
+ }
+ }
+
+ _, err := parseCreateParams(runtime)
+ return err
+ },
+ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ p, err := parseCreateParams(runtime)
+ if err != nil {
+ return common.NewDryRunAPI().
+ POST("").
+ Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
+ }
+
+ body := projectCreateRequestBody(okrCreateRequestBody{Content: p.Content, Notes: p.Notes, CategoryID: p.CategoryID})
+
+ if p.Level == "objective" {
+ params := projectCreateObjectiveQuery(okrCreateObjectiveQuery{
+ CycleID: p.CycleID,
+ UserIDType: p.UserIDType,
+ })
+ return common.NewDryRunAPI().
+ POST("/open-apis/okr/v2/cycles/:cycle_id/objectives").
+ Set("cycle_id", p.CycleID).
+ Params(params).
+ Body(body).
+ Desc("Create OKR objective")
+ }
+
+ params := projectCreateKeyResultQuery(okrCreateKeyResultQuery{
+ ObjectiveID: p.ObjectiveID,
+ UserIDType: p.UserIDType,
+ })
+ return common.NewDryRunAPI().
+ POST("/open-apis/okr/v2/objectives/:objective_id/key_results").
+ Set("objective_id", p.ObjectiveID).
+ Params(params).
+ Body(body).
+ Desc("Create OKR key result")
+ },
+ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ p, err := parseCreateParams(runtime)
+ if err != nil {
+ return err
+ }
+
+ body := projectCreateRequestBody(okrCreateRequestBody{Content: p.Content, Notes: p.Notes, CategoryID: p.CategoryID})
+
+ if p.Level == "objective" {
+ queryParams := projectCreateObjectiveQuery(okrCreateObjectiveQuery{
+ CycleID: p.CycleID,
+ UserIDType: p.UserIDType,
+ })
+ path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", p.CycleID)
+ data, err := runtime.CallAPITyped("POST", path, queryParams, body)
+ if err != nil {
+ return wrapOkrNetworkErr(err, "failed to create objective")
+ }
+ resp, err := projectCreateObjectiveResponse(data)
+ if err != nil {
+ return err
+ }
+ result := okrCreateObjectiveOutput{
+ Level: p.Level,
+ ObjectiveID: resp.ObjectiveID,
+ }
+
+ runtime.OutFormat(result, nil, func(w io.Writer) {
+ fmt.Fprintf(w, "Created OKR objective [%s]\n", resp.ObjectiveID)
+ })
+ return nil
+ }
+
+ queryParams := projectCreateKeyResultQuery(okrCreateKeyResultQuery{
+ ObjectiveID: p.ObjectiveID,
+ UserIDType: p.UserIDType,
+ })
+ path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", p.ObjectiveID)
+ data, err := runtime.CallAPITyped("POST", path, queryParams, body)
+ if err != nil {
+ return wrapOkrNetworkErr(err, "failed to create key result")
+ }
+ resp, err := projectCreateKeyResultResponse(data)
+ if err != nil {
+ return err
+ }
+ result := okrCreateKeyResultOutput{
+ Level: p.Level,
+ ObjectiveID: p.ObjectiveID,
+ KeyResultID: resp.KeyResultID,
+ }
+
+ runtime.OutFormat(result, nil, func(w io.Writer) {
+ fmt.Fprintf(w, "Created OKR key-result [%s] under objective [%s]\n", resp.KeyResultID, p.ObjectiveID)
+ })
+ return nil
+ },
+}
diff --git a/shortcuts/okr/okr_create_test.go b/shortcuts/okr/okr_create_test.go
new file mode 100644
index 000000000..a1890b08e
--- /dev/null
+++ b/shortcuts/okr/okr_create_test.go
@@ -0,0 +1,707 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package okr
+
+import (
+ "bytes"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/spf13/cobra"
+ "github.com/tidwall/gjson"
+)
+
+func createTestConfig(t *testing.T) *core.CliConfig {
+ t.Helper()
+ return &core.CliConfig{
+ AppID: "test-okr-create",
+ AppSecret: patchTestValue(),
+ Brand: core.BrandFeishu,
+ }
+}
+
+func runCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
+ t.Helper()
+ parent := &cobra.Command{Use: "okr"}
+ OKRCreate.Mount(parent, f)
+ parent.SetArgs(args)
+ parent.SilenceErrors = true
+ parent.SilenceUsage = true
+ if stdout != nil {
+ stdout.Reset()
+ }
+ return parent.Execute()
+}
+
+func runCreateShortcutWithStdin(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, stdin string, args []string) error {
+ t.Helper()
+ f.IOStreams.In = strings.NewReader(stdin)
+ return runCreateShortcut(t, f, stdout, args)
+}
+
+const (
+ validCreateSimpleJSON = `{"text":"test objective","mention":["ou_123"]}`
+ validCreateRichTextJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
+ emptyCreateRichTextJSON = `{"blocks":[]}`
+ blankCreateRichTextJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[]}}]}`
+ validCreateObjectiveArgs1 = "+create"
+)
+
+func TestCreateValidate_MissingLevel(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ validCreateObjectiveArgs1,
+ "--cycle-id", "123",
+ "--content", validCreateSimpleJSON,
+ })
+ if err == nil || !strings.Contains(err.Error(), "level") {
+ t.Fatalf("expected --level required error, got: %v", err)
+ }
+}
+
+func TestCreateValidate_InvalidLevel(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "invalid",
+ "--cycle-id", "123",
+ "--content", validCreateSimpleJSON,
+ })
+ if err == nil {
+ t.Fatal("expected invalid level error")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("expected typed invalid argument error, got: %v", err)
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--level" {
+ t.Fatalf("expected param --level, got: %v", err)
+ }
+}
+
+func TestCreateValidate_MissingCycleIDForObjective(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--content", validCreateSimpleJSON,
+ })
+ if err == nil {
+ t.Fatal("expected missing cycle-id error")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("expected typed invalid argument error, got: %v", err)
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
+ t.Fatalf("expected param --cycle-id, got: %v", err)
+ }
+}
+
+func TestCreateValidate_InvalidCycleID(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "abc",
+ "--content", validCreateSimpleJSON,
+ })
+ if err == nil {
+ t.Fatal("expected invalid cycle-id error")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("expected typed invalid argument error, got: %v", err)
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
+ t.Fatalf("expected param --cycle-id, got: %v", err)
+ }
+}
+
+func TestCreateValidate_MissingObjectiveIDForKR(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "key-result",
+ "--content", validCreateSimpleJSON,
+ })
+ if err == nil {
+ t.Fatal("expected missing objective-id error")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("expected typed invalid argument error, got: %v", err)
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--objective-id" {
+ t.Fatalf("expected param --objective-id, got: %v", err)
+ }
+}
+
+func TestCreateValidate_RejectObjectiveIDForObjective(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--objective-id", "456",
+ "--content", validCreateSimpleJSON,
+ })
+ if err == nil {
+ t.Fatal("expected objective-id rejection")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--objective-id" {
+ t.Fatalf("expected param --objective-id, got: %v", err)
+ }
+}
+
+func TestCreateValidate_RejectCycleIDForKeyResult(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "key-result",
+ "--cycle-id", "123",
+ "--objective-id", "456",
+ "--content", validCreateSimpleJSON,
+ })
+ if err == nil {
+ t.Fatal("expected cycle-id rejection")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
+ t.Fatalf("expected param --cycle-id, got: %v", err)
+ }
+}
+
+func TestCreateValidate_RejectNotesForKeyResult(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "key-result",
+ "--objective-id", "456",
+ "--content", validCreateSimpleJSON,
+ "--notes", `{"text":"objective only notes"}`,
+ })
+ if err == nil {
+ t.Fatal("expected notes rejection for key-result")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--notes" {
+ t.Fatalf("expected param --notes, got: %v", err)
+ }
+}
+
+func TestCreateValidate_RejectCategoryIDForKeyResult(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "key-result",
+ "--objective-id", "456",
+ "--content", validCreateSimpleJSON,
+ "--category-id", "123",
+ })
+ if err == nil {
+ t.Fatal("expected category-id rejection for key-result")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--category-id" {
+ t.Fatalf("expected param --category-id, got: %v", err)
+ }
+}
+
+func TestCreateValidate_ContentAndNotesCannotBothReadStdin(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcutWithStdin(t, f, stdout, `{"text":"stdin content"}`, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--content", "-",
+ "--notes", "-",
+ })
+ if err == nil {
+ t.Fatal("expected duplicate stdin error")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--notes" {
+ t.Fatalf("expected param --notes, got: %v", err)
+ }
+ if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
+ t.Fatalf("expected duplicate stdin error, got: %v", err)
+ }
+}
+
+func TestCreateValidate_InvalidObjectiveID(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "key-result",
+ "--objective-id", "0",
+ "--content", validCreateSimpleJSON,
+ })
+ if err == nil {
+ t.Fatal("expected invalid objective-id error")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("expected typed invalid argument error, got: %v", err)
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--objective-id" {
+ t.Fatalf("expected param --objective-id, got: %v", err)
+ }
+}
+
+func TestCreateValidate_InvalidStyle(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--style", "invalid",
+ "--content", validCreateSimpleJSON,
+ })
+ if err == nil {
+ t.Fatal("expected invalid style error")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--style" {
+ t.Fatalf("expected param --style, got: %v", err)
+ }
+}
+
+func TestCreateValidate_InvalidUserIDType(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--content", validCreateSimpleJSON,
+ "--user-id-type", "invalid",
+ })
+ if err == nil {
+ t.Fatal("expected invalid user-id-type error")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--user-id-type" {
+ t.Fatalf("expected param --user-id-type, got: %v", err)
+ }
+}
+
+func TestCreateValidate_MissingContent(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ })
+ if err == nil || !strings.Contains(err.Error(), "content") {
+ t.Fatalf("expected required content error, got: %v", err)
+ }
+}
+
+func TestCreateValidate_InvalidSimpleContentJSON(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--style", "simple",
+ "--content", "not-json",
+ })
+ if err == nil {
+ t.Fatal("expected invalid simple json error")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--content" {
+ t.Fatalf("expected param --content, got: %v", err)
+ }
+}
+
+func TestCreateValidate_EmptySimpleText(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--style", "simple",
+ "--content", `{"text":" "}`,
+ })
+ if err == nil {
+ t.Fatal("expected empty simple text error")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--content" {
+ t.Fatalf("expected param --content, got: %v", err)
+ }
+}
+
+func TestCreateValidate_EmptySimpleMention(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--style", "simple",
+ "--content", `{"text":"test","mention":[""]}`,
+ })
+ if err == nil {
+ t.Fatal("expected empty simple mention error")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--content" {
+ t.Fatalf("expected param --content, got: %v", err)
+ }
+}
+
+func TestCreateValidate_SimpleContentRejectsDocsImages(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--style", "simple",
+ "--content", `{"text":"test","docs":[{"title":"doc","url":"https://example.com"}],"images":["img"]}`,
+ })
+ if err == nil {
+ t.Fatal("expected docs/images rejection")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--content" {
+ t.Fatalf("expected param --content, got: %v", err)
+ }
+}
+
+func TestCreateValidate_SimpleContentRejectsUnknownFields(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--style", "simple",
+ "--content", `{"text":"test","mentions":["ou_123"]}`,
+ })
+ if err == nil {
+ t.Fatal("expected unknown simple content field error")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--content" {
+ t.Fatalf("expected param --content, got: %v", err)
+ }
+ if !strings.Contains(err.Error(), "unknown field") {
+ t.Fatalf("expected unknown field error, got: %v", err)
+ }
+}
+
+func TestCreateValidate_InvalidRichTextJSON(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--style", "richtext",
+ "--content", "not-json",
+ })
+ if err == nil {
+ t.Fatal("expected invalid richtext json error")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--content" {
+ t.Fatalf("expected param --content, got: %v", err)
+ }
+}
+
+func TestCreateValidate_RichTextRejectsUnknownFields(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--style", "richtext",
+ "--content", `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}],"mentions":["ou_123"]}`,
+ })
+ if err == nil {
+ t.Fatal("expected unknown richtext content field error")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--content" {
+ t.Fatalf("expected param --content, got: %v", err)
+ }
+ if !strings.Contains(err.Error(), "unknown field") {
+ t.Fatalf("expected unknown field error, got: %v", err)
+ }
+}
+
+func TestCreateValidate_EmptyRichTextContent(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ for _, content := range []string{emptyCreateRichTextJSON, blankCreateRichTextJSON} {
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--style", "richtext",
+ "--content", content,
+ })
+ if err == nil {
+ t.Fatalf("expected empty richtext error for %s", content)
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) || ve.Param != "--content" {
+ t.Fatalf("expected param --content, got: %v", err)
+ }
+ }
+}
+
+func TestCreateDryRun_Objective(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--content", validCreateSimpleJSON,
+ "--dry-run",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ output := stdout.String()
+ if got := gjson.Get(output, "data.api.0.method").String(); got != "POST" {
+ t.Fatalf("dry-run method = %q, want POST; output: %s", got, output)
+ }
+ if got := gjson.Get(output, "data.api.0.url").String(); got != "/open-apis/okr/v2/cycles/123/objectives" {
+ t.Fatalf("dry-run url = %q, want objective create path; output: %s", got, output)
+ }
+ if gjson.Get(output, "data.api.0.params.cycle_id").String() != "123" {
+ t.Fatalf("expected query params in dry-run, got: %s", output)
+ }
+ if gjson.Get(output, "data.api.0.params.user_id_type").String() != "open_id" {
+ t.Fatalf("expected default user-id-type in dry-run, got: %s", output)
+ }
+ if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(); got != "test objective" {
+ t.Fatalf("dry-run content text = %q, want test objective; output: %s", got, output)
+ }
+ if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_123" {
+ t.Fatalf("dry-run mention user_id = %q, want ou_123; output: %s", got, output)
+ }
+}
+
+func TestCreateDryRun_ObjectiveWithNotes(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--content", validCreateSimpleJSON,
+ "--notes", `{"text":"objective notes","mention":["ou_note"]}`,
+ "--dry-run",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ output := stdout.String()
+ if got := gjson.Get(output, "data.api.0.body.notes.blocks.0.paragraph.elements.0.text_run.text").String(); got != "objective notes" {
+ t.Fatalf("dry-run notes text = %q, want objective notes; output: %s", got, output)
+ }
+ if got := gjson.Get(output, "data.api.0.body.notes.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_note" {
+ t.Fatalf("dry-run notes mention user_id = %q, want ou_note; output: %s", got, output)
+ }
+}
+
+func TestCreateDryRun_ObjectiveWithCategoryID(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--content", validCreateSimpleJSON,
+ "--category-id", "7249339036661170180",
+ "--dry-run",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ output := stdout.String()
+ if got := gjson.Get(output, "data.api.0.body.category_id").String(); got != "7249339036661170180" {
+ t.Fatalf("dry-run category_id = %q, want 7249339036661170180; output: %s", got, output)
+ }
+}
+
+func TestCreateDryRun_KeyResult(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "key-result",
+ "--objective-id", "456",
+ "--style", "richtext",
+ "--content", validCreateRichTextJSON,
+ "--user-id-type", "union_id",
+ "--dry-run",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ output := stdout.String()
+ if got := gjson.Get(output, "data.api.0.method").String(); got != "POST" {
+ t.Fatalf("dry-run method = %q, want POST; output: %s", got, output)
+ }
+ if got := gjson.Get(output, "data.api.0.url").String(); got != "/open-apis/okr/v2/objectives/456/key_results" {
+ t.Fatalf("dry-run url = %q, want key result create path; output: %s", got, output)
+ }
+ if gjson.Get(output, "data.api.0.params.objective_id").String() != "456" {
+ t.Fatalf("expected objective-id query param in dry-run, got: %s", output)
+ }
+ if gjson.Get(output, "data.api.0.params.user_id_type").String() != "union_id" {
+ t.Fatalf("expected query params in dry-run, got: %s", output)
+ }
+ if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(); got != "test content" {
+ t.Fatalf("dry-run richtext content = %q, want test content; output: %s", got, output)
+ }
+}
+
+func TestCreateExecute_ObjectiveSuccess(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/okr/v2/cycles/123/objectives",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "objective_id": "1001",
+ },
+ },
+ })
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--content", validCreateSimpleJSON,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ data := decodeEnvelope(t, stdout)
+ level, _ := data["level"].(string)
+ if level != "objective" {
+ t.Fatalf("expected level objective, got %v", data["level"])
+ }
+ if data["objective_id"] != "1001" {
+ t.Fatalf("expected objective_id=1001, got %v", data["objective_id"])
+ }
+}
+
+func TestCreateExecute_KeyResultSuccess(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/okr/v2/objectives/456/key_results",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "key_result_id": "2001",
+ },
+ },
+ })
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "key-result",
+ "--objective-id", "456",
+ "--content", validCreateSimpleJSON,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ data := decodeEnvelope(t, stdout)
+ level, _ := data["level"].(string)
+ if level != "key-result" {
+ t.Fatalf("expected level key-result, got %v", data["level"])
+ }
+ if data["key_result_id"] != "2001" {
+ t.Fatalf("expected key_result_id=2001, got %v", data["key_result_id"])
+ }
+ if data["objective_id"] != "456" {
+ t.Fatalf("expected objective_id=456, got %v", data["objective_id"])
+ }
+}
+
+func TestCreateExecute_ObjectiveAPITypedErrorPassThrough(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/okr/v2/cycles/123/objectives",
+ Status: 400,
+ Body: map[string]interface{}{
+ "code": 1001001,
+ "msg": "invalid parameters",
+ },
+ })
+ err := runCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--level", "objective",
+ "--cycle-id", "123",
+ "--content", validCreateSimpleJSON,
+ })
+ if err == nil {
+ t.Fatal("expected API error")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryAPI {
+ t.Fatalf("expected typed API error, got: %v", err)
+ }
+}
+
+func TestCreateExecute_KeyResultRawErrorWrappedAsNetworkError(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
+ raw := errors.New("dial tcp: i/o timeout")
+ got := wrapOkrNetworkErr(raw, "failed to create key result")
+ problem, ok := errs.ProblemOf(got)
+ if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
+ t.Fatalf("expected network transport error, got: %v", got)
+ }
+ if !errors.Is(got, raw) {
+ t.Fatal("expected wrapped raw error to be preserved")
+ }
+ if stdout.String() != "" || f == nil {
+ // keep the test factory referenced so the helper wiring stays exercised
+ }
+}
diff --git a/shortcuts/okr/okr_cycle_list.go b/shortcuts/okr/okr_cycle_list.go
index 4055908cb..f6d6f7426 100644
--- a/shortcuts/okr/okr_cycle_list.go
+++ b/shortcuts/okr/okr_cycle_list.go
@@ -64,6 +64,10 @@ func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
cycleStart := time.UnixMilli(startMs).UTC()
cycleEnd := time.UnixMilli(endMs).UTC()
nowUTC := now.UTC()
+ // Month cycles only
+ if cycleStart.AddDate(1, 0, -1) == cycleEnd {
+ return false
+ }
// Check time range: now must be >= start and <= end
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
@@ -78,6 +82,7 @@ func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
return status == CycleStatusDefault || status == CycleStatusNormal
}
+// OKRListCycles
var OKRListCycles = common.Shortcut{
Service: "okr",
Command: "+cycle-list",
@@ -89,7 +94,9 @@ var OKRListCycles = common.Shortcut{
Flags: []common.Flag{
{Name: "user-id", Desc: "user ID", Required: true},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
- {Name: "time-range", Desc: "specify time range. Use Format as YYYY-MM--YYYY-MM. leave empty to fetch all user cycles."},
+ {Name: "time-range", Desc: "local post-filter applied after the requested page is fetched. Format: YYYY-MM--YYYY-MM. Leave empty to keep the page unfiltered."},
+ {Name: "page-size", Type: "int", Default: "100", Desc: "page size, range 1-100"},
+ {Name: "page-token", Desc: "pagination token from previous response"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
idType := runtime.Str("user-id-type")
@@ -110,18 +117,29 @@ var OKRListCycles = common.Shortcut{
return err
}
}
+ if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 100); err != nil {
+ return err
+ }
+ if pageToken := runtime.Str("page-token"); pageToken != "" {
+ if err := common.RejectDangerousCharsTyped("--page-token", pageToken); err != nil {
+ return err
+ }
+ }
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{
"user_id": runtime.Str("user-id"),
"user_id_type": runtime.Str("user-id-type"),
- "page_size": 100,
+ "page_size": runtime.Int("page-size"),
+ }
+ if pageToken := runtime.Str("page-token"); pageToken != "" {
+ params["page_token"] = pageToken
}
return common.NewDryRunAPI().
GET("/open-apis/okr/v2/cycles").
Params(params).
- Desc("List OKR cycles for user, paginated at 100 per page, filtered by time-range")
+ Desc("List one page of OKR cycles for user; --time-range is a local post-filter on the returned page")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
userID := runtime.Str("user-id")
@@ -140,53 +158,35 @@ var OKRListCycles = common.Shortcut{
hasRange = true
}
- // Paginated fetch of all cycles
queryParams := map[string]interface{}{
"user_id": userID,
"user_id_type": userIDType,
- "page_size": "100",
+ "page_size": runtime.Int("page-size"),
+ }
+ if pageToken := runtime.Str("page-token"); pageToken != "" {
+ queryParams["page_token"] = pageToken
}
var allCycles []Cycle
- page := 0
- for {
- if err := ctx.Err(); err != nil {
- return err
- }
- if page > 0 {
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-time.After(500 * time.Millisecond):
- }
- }
- page++
-
- data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
- if err != nil {
- return err
- }
-
- itemsRaw, _ := data["items"].([]interface{})
- for _, item := range itemsRaw {
- raw, err := json.Marshal(item)
- if err != nil {
- continue
- }
- var cycle Cycle
- if err := json.Unmarshal(raw, &cycle); err != nil {
- continue
- }
- allCycles = append(allCycles, cycle)
- }
-
- hasMore, pageToken := common.PaginationMeta(data)
- if !hasMore || pageToken == "" {
- break
- }
- queryParams["page_token"] = pageToken
+ data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
+ if err != nil {
+ return err
}
+ itemsRaw, _ := data["items"].([]interface{})
+ for _, item := range itemsRaw {
+ raw, err := json.Marshal(item)
+ if err != nil {
+ continue
+ }
+ var cycle Cycle
+ if err := json.Unmarshal(raw, &cycle); err != nil {
+ continue
+ }
+ allCycles = append(allCycles, cycle)
+ }
+ hasMore, nextPageToken := common.PaginationMeta(data)
+
// Filter by time-range overlap
var filtered []Cycle
for i := range allCycles {
@@ -212,7 +212,8 @@ var OKRListCycles = common.Shortcut{
runtime.OutFormat(map[string]interface{}{
"cycles": respCycles,
- "total": len(respCycles),
+ "has_more": hasMore,
+ "page_token": nextPageToken,
"current_active_cycles": currentActiveCycles,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
diff --git a/shortcuts/okr/okr_cycle_list_test.go b/shortcuts/okr/okr_cycle_list_test.go
index 951cbcdad..92e2ca4f6 100644
--- a/shortcuts/okr/okr_cycle_list_test.go
+++ b/shortcuts/okr/okr_cycle_list_test.go
@@ -5,6 +5,8 @@ package okr
import (
"bytes"
+ "net/http"
+ "net/url"
"strconv"
"strings"
"testing"
@@ -12,6 +14,7 @@ import (
"github.com/spf13/cobra"
+ "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -120,6 +123,27 @@ func TestCycleListValidate_StartAfterEndTimeRange(t *testing.T) {
}
}
+func TestCycleListValidate_InvalidPageSize(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
+ err := runCycleListShortcut(t, f, stdout, []string{
+ "+cycle-list",
+ "--user-id", "ou-123",
+ "--page-size", "101",
+ })
+ if err == nil {
+ t.Fatal("expected error for invalid --page-size")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("expected validation invalid_argument problem, got: %v", err)
+ }
+ validationErr, ok := err.(*errs.ValidationError)
+ if !ok || validationErr.Param != "--page-size" {
+ t.Fatalf("expected param --page-size, got: %v", err)
+ }
+}
+
func TestCycleListValidate_ValidNoTimeRange(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
@@ -214,6 +238,9 @@ func TestCycleListDryRun(t *testing.T) {
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
t.Fatalf("dry-run output should contain API path, got: %s", output)
}
+ if !strings.Contains(output, "\"page_size\": 100") {
+ t.Fatalf("dry-run output should contain default page_size=100, got: %s", output)
+ }
}
func TestCycleListDryRun_WithTimeRange(t *testing.T) {
@@ -234,6 +261,28 @@ func TestCycleListDryRun_WithTimeRange(t *testing.T) {
}
}
+func TestCycleListDryRun_WithPagination(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
+ err := runCycleListShortcut(t, f, stdout, []string{
+ "+cycle-list",
+ "--user-id", "ou-789",
+ "--page-size", "20",
+ "--page-token", "next-page",
+ "--dry-run",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ output := stdout.String()
+ if !strings.Contains(output, "\"page_size\": 20") {
+ t.Fatalf("dry-run output should contain page_size=20, got: %s", output)
+ }
+ if !strings.Contains(output, "\"page_token\": \"next-page\"") {
+ t.Fatalf("dry-run output should contain page_token, got: %s", output)
+ }
+}
+
// --- Execute tests ---
func TestCycleListExecute_NoCycles(t *testing.T) {
@@ -454,9 +503,11 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
if len(cycles) != 2 {
t.Fatalf("cycles count = %d, want 2", len(cycles))
}
- total, _ := data["total"].(float64)
- if int(total) != 2 {
- t.Fatalf("total = %v, want 2", total)
+ if _, ok := data["total"]; ok {
+ t.Fatal("total should not be present in response")
+ }
+ if hasMore, _ := data["has_more"].(bool); hasMore {
+ t.Fatalf("has_more = %v, want false", hasMore)
}
// Check current_active_cycles - should only contain cycle-active
@@ -555,10 +606,13 @@ func TestCycleListExecute_Pagination(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
- // First page
+ var gotQuery url.Values
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
+ OnMatch: func(req *http.Request) {
+ gotQuery = req.URL.Query()
+ },
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
@@ -578,38 +632,31 @@ func TestCycleListExecute_Pagination(t *testing.T) {
},
})
- // Second page
- reg.Register(&httpmock.Stub{
- Method: "GET",
- URL: "/open-apis/okr/v2/cycles",
- Body: map[string]interface{}{
- "code": 0,
- "msg": "ok",
- "data": map[string]interface{}{
- "items": []interface{}{
- map[string]interface{}{
- "id": "cycle-p2",
- "start_time": "1738368000000",
- "end_time": "1743465600000",
- "cycle_status": 1,
- "owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
- },
- },
- },
- },
- })
-
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
+ "--page-size", "1",
+ "--page-token", "start_page",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
+ if got := gotQuery.Get("page_size"); got != "1" {
+ t.Fatalf("query page_size = %q, want 1", got)
+ }
+ if got := gotQuery.Get("page_token"); got != "start_page" {
+ t.Fatalf("query page_token = %q, want start_page", got)
+ }
data := decodeEnvelope(t, stdout)
cycles, _ := data["cycles"].([]interface{})
- if len(cycles) != 2 {
- t.Fatalf("cycles count = %d, want 2", len(cycles))
+ if len(cycles) != 1 {
+ t.Fatalf("cycles count = %d, want 1", len(cycles))
+ }
+ if hasMore, _ := data["has_more"].(bool); !hasMore {
+ t.Fatalf("has_more = %v, want true", hasMore)
+ }
+ if pageToken, _ := data["page_token"].(string); pageToken != "next_page" {
+ t.Fatalf("page_token = %q, want next_page", pageToken)
}
}
diff --git a/shortcuts/okr/okr_progress_list.go b/shortcuts/okr/okr_progress_list.go
index 6865d241f..c1d483df5 100644
--- a/shortcuts/okr/okr_progress_list.go
+++ b/shortcuts/okr/okr_progress_list.go
@@ -28,6 +28,8 @@ var OKRListProgress = common.Shortcut{
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "department-id-type", Default: "open_department_id", Desc: "department ID type: department_id | open_department_id"},
+ {Name: "page-size", Type: "int", Default: "100", Desc: "page size, range 1-100"},
+ {Name: "page-token", Desc: "pagination token from previous response"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
targetID := runtime.Str("target-id")
@@ -55,6 +57,14 @@ var OKRListProgress = common.Shortcut{
if deptIDType != "department_id" && deptIDType != "open_department_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--department-id-type must be one of: department_id | open_department_id").WithParam("--department-id-type")
}
+ if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 100); err != nil {
+ return err
+ }
+ if pageToken := runtime.Str("page-token"); pageToken != "" {
+ if err := common.RejectDangerousCharsTyped("--page-token", pageToken); err != nil {
+ return err
+ }
+ }
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -63,7 +73,10 @@ var OKRListProgress = common.Shortcut{
params := map[string]interface{}{
"user_id_type": runtime.Str("user-id-type"),
"department_id_type": runtime.Str("department-id-type"),
- "page_size": 100,
+ "page_size": runtime.Int("page-size"),
+ }
+ if pageToken := runtime.Str("page-token"); pageToken != "" {
+ params["page_token"] = pageToken
}
switch targetType {
@@ -91,7 +104,10 @@ var OKRListProgress = common.Shortcut{
queryParams := map[string]interface{}{
"user_id_type": userIDType,
"department_id_type": deptIDType,
- "page_size": "100",
+ "page_size": runtime.Int("page-size"),
+ }
+ if pageToken := runtime.Str("page-token"); pageToken != "" {
+ queryParams["page_token"] = pageToken
}
var apiPath string
@@ -103,36 +119,29 @@ var OKRListProgress = common.Shortcut{
}
var allProgress []*Progress
- for {
- if err := ctx.Err(); err != nil {
- return err
- }
-
- data, err := runtime.CallAPITyped("GET", apiPath, queryParams, nil)
- if err != nil {
- return err
- }
-
- itemsRaw, _ := data["items"].([]interface{})
- for _, item := range itemsRaw {
- raw, err := json.Marshal(item)
- if err != nil {
- continue
- }
- var progress Progress
- if err := json.Unmarshal(raw, &progress); err != nil {
- continue
- }
- allProgress = append(allProgress, &progress)
- }
-
- hasMore, pageToken := common.PaginationMeta(data)
- if !hasMore || pageToken == "" {
- break
- }
- queryParams["page_token"] = pageToken
+ if err := ctx.Err(); err != nil {
+ return err
}
+ data, err := runtime.CallAPITyped("GET", apiPath, queryParams, nil)
+ if err != nil {
+ return err
+ }
+
+ itemsRaw, _ := data["items"].([]interface{})
+ for _, item := range itemsRaw {
+ raw, err := json.Marshal(item)
+ if err != nil {
+ continue
+ }
+ var progress Progress
+ if err := json.Unmarshal(raw, &progress); err != nil {
+ continue
+ }
+ allProgress = append(allProgress, &progress)
+ }
+ hasMore, pageToken := common.PaginationMeta(data)
+
// Convert to response format
respProgress := make([]*RespProgress, 0, len(allProgress))
for _, p := range allProgress {
@@ -141,7 +150,8 @@ var OKRListProgress = common.Shortcut{
runtime.OutFormat(map[string]interface{}{
"progress_list": respProgress,
- "total": len(respProgress),
+ "has_more": hasMore,
+ "page_token": pageToken,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Found %d progress(es)\n", len(respProgress))
for _, p := range respProgress {
diff --git a/shortcuts/okr/okr_progress_list_test.go b/shortcuts/okr/okr_progress_list_test.go
index 8a63f7906..fb2d98fa3 100644
--- a/shortcuts/okr/okr_progress_list_test.go
+++ b/shortcuts/okr/okr_progress_list_test.go
@@ -5,11 +5,14 @@ package okr
import (
"bytes"
+ "net/http"
+ "net/url"
"strings"
"testing"
"github.com/spf13/cobra"
+ "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -123,6 +126,28 @@ func TestProgressListValidate_InvalidDepartmentIDType(t *testing.T) {
}
}
+func TestProgressListValidate_InvalidPageSize(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t))
+ err := runProgressListShortcut(t, f, stdout, []string{
+ "+progress-list",
+ "--target-id", "123",
+ "--target-type", "objective",
+ "--page-size", "0",
+ })
+ if err == nil {
+ t.Fatal("expected error for invalid --page-size")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("expected validation invalid_argument problem, got: %v", err)
+ }
+ validationErr, ok := err.(*errs.ValidationError)
+ if !ok || validationErr.Param != "--page-size" {
+ t.Fatalf("expected param --page-size, got: %v", err)
+ }
+}
+
// --- DryRun tests ---
func TestProgressListDryRun_Objective(t *testing.T) {
@@ -144,6 +169,9 @@ func TestProgressListDryRun_Objective(t *testing.T) {
if !strings.Contains(output, "GET") {
t.Fatalf("dry-run output should contain GET method, got: %s", output)
}
+ if !strings.Contains(output, "\"page_size\": 100") {
+ t.Fatalf("dry-run output should contain default page_size=100, got: %s", output)
+ }
}
func TestProgressListDryRun_KeyResult(t *testing.T) {
@@ -164,14 +192,41 @@ func TestProgressListDryRun_KeyResult(t *testing.T) {
}
}
+func TestProgressListDryRun_WithPagination(t *testing.T) {
+ t.Parallel()
+ f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t))
+ err := runProgressListShortcut(t, f, stdout, []string{
+ "+progress-list",
+ "--target-id", "123456789",
+ "--target-type", "objective",
+ "--page-size", "25",
+ "--page-token", "next-page",
+ "--dry-run",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ output := stdout.String()
+ if !strings.Contains(output, "\"page_size\": 25") {
+ t.Fatalf("dry-run output should contain page_size=25, got: %s", output)
+ }
+ if !strings.Contains(output, "\"page_token\": \"next-page\"") {
+ t.Fatalf("dry-run output should contain page_token, got: %s", output)
+ }
+}
+
// --- Execute tests ---
func TestProgressListExecute_Success_Objective(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressListTestConfig(t))
+ var gotQuery url.Values
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123456789/progresses",
+ OnMatch: func(req *http.Request) {
+ gotQuery = req.URL.Query()
+ },
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
@@ -191,7 +246,8 @@ func TestProgressListExecute_Success_Objective(t *testing.T) {
},
},
},
- "has_more": false,
+ "has_more": true,
+ "page_token": "next_page",
},
},
})
@@ -199,15 +255,32 @@ func TestProgressListExecute_Success_Objective(t *testing.T) {
"+progress-list",
"--target-id", "123456789",
"--target-type", "objective",
+ "--page-size", "50",
+ "--page-token", "start_page",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
+ if got := gotQuery.Get("page_size"); got != "50" {
+ t.Fatalf("query page_size = %q, want 50", got)
+ }
+ if got := gotQuery.Get("page_token"); got != "start_page" {
+ t.Fatalf("query page_token = %q, want start_page", got)
+ }
data := decodeEnvelope(t, stdout)
records, _ := data["progress_list"].([]interface{})
if len(records) != 1 {
t.Fatalf("expected 1 progress, got %d", len(records))
}
+ if _, ok := data["total"]; ok {
+ t.Fatal("total should not be present in response")
+ }
+ if hasMore, _ := data["has_more"].(bool); !hasMore {
+ t.Fatalf("has_more = %v, want true", hasMore)
+ }
+ if pageToken, _ := data["page_token"].(string); pageToken != "next_page" {
+ t.Fatalf("page_token = %q, want next_page", pageToken)
+ }
}
func TestProgressListExecute_Success_KeyResult(t *testing.T) {
diff --git a/shortcuts/okr/shortcuts.go b/shortcuts/okr/shortcuts.go
index 5b371e690..d78bf54ba 100644
--- a/shortcuts/okr/shortcuts.go
+++ b/shortcuts/okr/shortcuts.go
@@ -18,6 +18,7 @@ func Shortcuts() []common.Shortcut {
OKRUpdateProgressRecord,
OKRDeleteProgressRecord,
OKRUploadImage,
+ OKRCreate,
OKRBatchCreate,
OKRReorder,
OKRWeight,
diff --git a/shortcuts/okr/shortcuts_test.go b/shortcuts/okr/shortcuts_test.go
index 00cb1f486..4900e54aa 100644
--- a/shortcuts/okr/shortcuts_test.go
+++ b/shortcuts/okr/shortcuts_test.go
@@ -12,6 +12,12 @@ import (
func TestShortcutsRegistration(t *testing.T) {
convey.Convey("Shortcuts() returns all commands", t, func() {
list := Shortcuts()
- convey.So(len(list), convey.ShouldBeGreaterThan, 0)
+ commands := make([]string, 0, len(list))
+ for _, shortcut := range list {
+ commands = append(commands, shortcut.Command)
+ }
+ convey.So(commands, convey.ShouldContain, "+create")
+ convey.So(commands, convey.ShouldContain, "+batch-create")
+ convey.So(commands, convey.ShouldContain, "+patch")
})
}
diff --git a/shortcuts/sheets/backward/lark_sheets_float_images.go b/shortcuts/sheets/backward/lark_sheets_float_images.go
index 9efaa5dc1..e0ebc79e7 100644
--- a/shortcuts/sheets/backward/lark_sheets_float_images.go
+++ b/shortcuts/sheets/backward/lark_sheets_float_images.go
@@ -17,9 +17,10 @@ import (
)
// Drive media parent_type values for uploading an image into a spreadsheet.
-// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
-// synthetic token prefixed with "fake_office_" (being renamed to
-// "local_office_") and the backend requires "office_sheet_file" instead.
+// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
+// legacy synthetic-token prefix or a 28-character token whose interleaved
+// product/region marker is "OFL0X". The backend requires
+// "office_sheet_file" for those imported spreadsheets.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
@@ -27,22 +28,37 @@ const (
localOfficePrefix = "local_office_"
)
-// officePrefixes 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.
+// officePrefixes are the legacy synthetic token prefixes an imported "office"
+// spreadsheet may carry.
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
-// sheetMediaParentType returns the drive media parent_type to use when
-// uploading an image whose parent_node is spreadsheetToken, mapping either the
-// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
-// "office_sheet_file".
-func sheetMediaParentType(spreadsheetToken string) string {
+func isOfficeSpreadsheet(spreadsheetToken string) bool {
for _, prefix := range officePrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
- return officeSheetFileParentType
+ return true
}
}
+ if len(spreadsheetToken) != 28 {
+ return false
+ }
+ // The five-character marker occupies positions 5, 10, 15, 20, and 25
+ // (1-based) in the interleaved token.
+ marker := []byte{
+ spreadsheetToken[4],
+ spreadsheetToken[9],
+ spreadsheetToken[14],
+ spreadsheetToken[19],
+ spreadsheetToken[24],
+ }
+ return string(marker) == "OFL0X"
+}
+
+// sheetMediaParentType returns the drive media parent_type to use when
+// uploading an image whose parent_node is spreadsheetToken.
+func sheetMediaParentType(spreadsheetToken string) string {
+ if isOfficeSpreadsheet(spreadsheetToken) {
+ return officeSheetFileParentType
+ }
return sheetImageParentType
}
diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_create_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_create_test.go
index cfd4781c4..a67761b0d 100644
--- a/shortcuts/sheets/backward/lark_sheets_sheet_create_test.go
+++ b/shortcuts/sheets/backward/lark_sheets_sheet_create_test.go
@@ -64,7 +64,7 @@ func TestSheetCreateBotAutoGrantSuccess(t *testing.T) {
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
- if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new spreadsheet." {
+ if grant["message"] != "Granted the current CLI user full_access on the new spreadsheet." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
@@ -156,10 +156,26 @@ func TestSheetCreateDryRunIncludesFolderToken(t *testing.T) {
"data": "",
},
nil, nil)
+ rt = common.TestNewRuntimeContextWithIdentity(rt.Cmd, nil, core.AsBot)
got := mustMarshalSheetsDryRun(t, SheetCreate.DryRun(context.Background(), rt))
if !strings.Contains(got, `"folder_token":"fldcn123"`) {
t.Fatalf("DryRun should include folder_token, got: %s", got)
}
+ var dryRun struct {
+ API []struct {
+ Desc string `json:"desc"`
+ } `json:"api"`
+ }
+ if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
+ t.Fatalf("unmarshal dry run: %v", err)
+ }
+ if len(dryRun.API) != 1 {
+ t.Fatalf("dry-run API count = %d, want 1", len(dryRun.API))
+ }
+ wantDesc := "After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new spreadsheet."
+ if dryRun.API[0].Desc != wantDesc {
+ t.Fatalf("desc = %q, want %q", dryRun.API[0].Desc, wantDesc)
+ }
}
func TestSheetCreatePreservesBackendURL(t *testing.T) {
diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go
index 40b31d315..b52a250e8 100644
--- a/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go
+++ b/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go
@@ -105,7 +105,7 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
- "--spreadsheet-token", "fake_office_abc123",
+ "--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--file", "img.png",
"--dry-run", "--as", "user",
}, f, stdout)
@@ -117,10 +117,10 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
}
if !strings.Contains(out, `"office_sheet_file"`) {
- t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out)
+ t.Fatalf("dry-run should include parent_type=office_sheet_file for interleaved OFL0X token, got: %s", out)
}
if strings.Contains(out, `"sheet_image"`) {
- t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out)
+ t.Fatalf("dry-run must not emit sheet_image for interleaved OFL0X token, got: %s", out)
}
}
@@ -239,7 +239,7 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
}
// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported
-// "office" spreadsheet (token prefixed with "fake_office_") uploads with
+// "office" spreadsheet (token carrying the interleaved "OFL0X" marker) uploads with
// parent_type=office_sheet_file instead of the native sheet_image.
func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
dir := t.TempDir()
@@ -259,7 +259,7 @@ func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
}
reg.Register(stub)
- const officeToken = "fake_office_abc123"
+ const officeToken = "aaaaOaaaaFaaaaLaaaa0aaaaXaaa"
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", officeToken,
diff --git a/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go
index 2f9314f92..f06b5b2ad 100644
--- a/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go
+++ b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go
@@ -115,7 +115,7 @@ var SheetCreate = common.Shortcut{
POST("/open-apis/sheets/v3/spreadsheets").
Body(body)
if runtime.IsBot() {
- d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new spreadsheet.")
+ d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new spreadsheet.")
}
return d
},
diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go
index e9281c929..fae9f2122 100644
--- a/shortcuts/sheets/helpers.go
+++ b/shortcuts/sheets/helpers.go
@@ -52,9 +52,10 @@ func sheetsInputStatError(flag string, err error) error {
}
// Drive media parent_type values for uploading an image into a spreadsheet.
-// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
-// synthetic token prefixed with "fake_office_" (being renamed to
-// "local_office_") and the backend requires "office_sheet_file" instead.
+// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
+// legacy synthetic-token prefix or a 28-character token whose interleaved
+// product/region marker is "OFL0X". The backend requires
+// "office_sheet_file" for those imported spreadsheets.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
@@ -62,21 +63,38 @@ const (
localOfficePrefix = "local_office_"
)
-// officePrefixes 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.
+// officePrefixes are the legacy synthetic token prefixes an imported "office"
+// spreadsheet may carry.
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
+func isOfficeSpreadsheet(spreadsheetToken string) bool {
+ for _, prefix := range officePrefixes {
+ if strings.HasPrefix(spreadsheetToken, prefix) {
+ return true
+ }
+ }
+ if len(spreadsheetToken) != 28 {
+ return false
+ }
+ // The five-character marker occupies positions 5, 10, 15, 20, and 25
+ // (1-based) in the interleaved token.
+ marker := []byte{
+ spreadsheetToken[4],
+ spreadsheetToken[9],
+ spreadsheetToken[14],
+ spreadsheetToken[19],
+ spreadsheetToken[24],
+ }
+ return string(marker) == "OFL0X"
+}
+
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken. It is the single
// place that maps a spreadsheet token to its parent_type so every image-upload
// entry point (and its dry-run preview) stays consistent.
func sheetMediaParentType(spreadsheetToken string) string {
- for _, prefix := range officePrefixes {
- if strings.HasPrefix(spreadsheetToken, prefix) {
- return officeSheetFileParentType
- }
+ if isOfficeSpreadsheet(spreadsheetToken) {
+ return officeSheetFileParentType
}
return sheetImageParentType
}
diff --git a/shortcuts/sheets/sheet_media_parent_type_test.go b/shortcuts/sheets/sheet_media_parent_type_test.go
index d37bd0737..395be8764 100644
--- a/shortcuts/sheets/sheet_media_parent_type_test.go
+++ b/shortcuts/sheets/sheet_media_parent_type_test.go
@@ -25,8 +25,9 @@ import (
// TestSheetMediaParentType pins the token→parent_type mapping that every
// sheets image-upload entry point funnels through. Native spreadsheet tokens
-// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
-// "local_office_" synthetic token and must upload with "office_sheet_file".
+// use "sheet_image"; imported "office" spreadsheets use either a legacy
+// prefix or the interleaved "OFL0X" marker and must upload with
+// "office_sheet_file".
func TestSheetMediaParentType(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -40,6 +41,13 @@ func TestSheetMediaParentType(t *testing.T) {
{"fake_office token, only the prefix", fakeOfficePrefix, officeSheetFileParentType},
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
{"local_office token, only the prefix", localOfficePrefix, officeSheetFileParentType},
+ {"interleaved OFL0X office token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
+ {"interleaved exlcn token", "abcdeefghxijkllmnopcqrstnuv", sheetImageParentType},
+ {"interleaved shtcn native token", "abcdsefghhijkltmnopcqrstnuv", sheetImageParentType},
+ {"interleaved pptcn token", "abcdpefghpijkltmnopcqrstnuv", sheetImageParentType},
+ {"interleaved wodcn token", "abcdwefghoijkldmnopcqrstnuv", sheetImageParentType},
+ {"interleaved OFL0X marker with short length", "aaaaOaaaaFaaaaLaaaa0aaaaXaa", sheetImageParentType},
+ {"interleaved OFL0X marker with long length", "aaaaOaaaaFaaaaLaaaa0aaaaXaaaa", sheetImageParentType},
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
}
@@ -57,7 +65,7 @@ func TestSheetMediaParentType(t *testing.T) {
// to end (the Execute path the dry-run tests don't reach), asserting the
// parent_type that actually goes out on the wire is derived from the token: a
// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet
-// (fake_office_-prefixed token) as office_sheet_file.
+// (legacy prefix or interleaved OFL0X marker) as office_sheet_file.
func TestUploadSheetImage_ParentType(t *testing.T) {
cases := []struct {
name string
@@ -67,6 +75,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
+ {"interleaved OFL0X imported spreadsheet", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
diff --git a/shortcuts/slides/shortcuts.go b/shortcuts/slides/shortcuts.go
index 011e27e0b..729e729e6 100644
--- a/shortcuts/slides/shortcuts.go
+++ b/shortcuts/slides/shortcuts.go
@@ -3,16 +3,67 @@
package slides
-import "github.com/larksuite/cli/shortcuts/common"
+import (
+ "github.com/larksuite/cli/shortcuts/common"
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+var presentationFlagAliases = []string{
+ "presentation-id",
+ "presentation-token",
+ "token",
+ "presentation_id",
+ "xml-presentation-id",
+ "url",
+}
// Shortcuts returns all slides shortcuts.
func Shortcuts() []common.Shortcut {
- return []common.Shortcut{
+ all := []common.Shortcut{
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesReplacePages,
SlidesScreenshot,
SlidesXMLGet,
+ SlidesHistoryList,
+ SlidesHistoryRevert,
+ SlidesHistoryRevertStatus,
+ }
+ for i := range all {
+ if hasPresentationFlag(all[i].Flags) {
+ all[i].PostMount = withPresentationFlagAliases(all[i].PostMount)
+ }
+ }
+ return all
+}
+
+func hasPresentationFlag(flags []common.Flag) bool {
+ for _, flag := range flags {
+ if flag.Name == "presentation" {
+ return true
+ }
+ }
+ return false
+}
+
+// withPresentationFlagAliases accepts common agent-generated spellings for
+// --presentation without registering extra flags. The aliases therefore stay
+// out of help and completion while resolving to the canonical flag at parse
+// time, matching the zero-round-trip compatibility used by Sheets.
+func withPresentationFlagAliases(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
+ return func(cmd *cobra.Command) {
+ if prev != nil {
+ prev(cmd)
+ }
+ cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
+ for _, alias := range presentationFlagAliases {
+ if name == alias {
+ return pflag.NormalizedName("presentation")
+ }
+ }
+ return pflag.NormalizedName(name)
+ })
}
}
diff --git a/shortcuts/slides/shortcuts_alias_test.go b/shortcuts/slides/shortcuts_alias_test.go
new file mode 100644
index 000000000..72ab60863
--- /dev/null
+++ b/shortcuts/slides/shortcuts_alias_test.go
@@ -0,0 +1,68 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package slides
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+func TestWithPresentationFlagAliases(t *testing.T) {
+ for _, alias := range presentationFlagAliases {
+ t.Run(alias, func(t *testing.T) {
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("presentation", "", "presentation reference")
+ withPresentationFlagAliases(nil)(cmd)
+
+ if err := cmd.Flags().Parse([]string{"--" + alias, "presABC"}); err != nil {
+ t.Fatalf("--%s should resolve to --presentation: %v", alias, err)
+ }
+ got, err := cmd.Flags().GetString("presentation")
+ if err != nil {
+ t.Fatalf("read --presentation: %v", err)
+ }
+ if got != "presABC" {
+ t.Fatalf("--%s set --presentation to %q, want presABC", alias, got)
+ }
+ if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
+ t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
+ }
+ })
+ }
+}
+
+func TestShortcutsAttachPresentationFlagAliases(t *testing.T) {
+ count := 0
+ for _, shortcut := range Shortcuts() {
+ if !hasPresentationFlag(shortcut.Flags) {
+ continue
+ }
+ count++
+ if shortcut.PostMount == nil {
+ t.Errorf("%s has --presentation but no compatibility normalizer", shortcut.Command)
+ continue
+ }
+
+ cmd := &cobra.Command{Use: shortcut.Command}
+ cmd.Flags().String("presentation", "", "presentation reference")
+ shortcut.PostMount(cmd)
+ if err := cmd.Flags().Parse([]string{"--token", "presABC"}); err != nil {
+ t.Errorf("%s did not normalize --token: %v", shortcut.Command, err)
+ continue
+ }
+ got, err := cmd.Flags().GetString("presentation")
+ if err != nil {
+ t.Errorf("%s could not read --presentation: %v", shortcut.Command, err)
+ continue
+ }
+ if got != "presABC" {
+ t.Errorf("%s normalized --token to %q, want presABC", shortcut.Command, got)
+ }
+ }
+ if count == 0 {
+ t.Fatal("expected at least one slides shortcut with --presentation")
+ }
+}
diff --git a/shortcuts/slides/slides_create.go b/shortcuts/slides/slides_create.go
index b03fea491..4a7cbf1ad 100644
--- a/shortcuts/slides/slides_create.go
+++ b/shortcuts/slides/slides_create.go
@@ -118,7 +118,7 @@ var SlidesCreate = common.Shortcut{
}
if runtime.IsBot() {
- dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
+ dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new presentation.")
}
return dry
},
@@ -154,6 +154,9 @@ var SlidesCreate = common.Shortcut{
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
result["revision_id"] = int(revisionID)
}
+ if issues, ok := data["issues"]; ok {
+ result["issues"] = issues
+ }
// Step 2: Add slides if provided
if slidesStr != "" {
@@ -182,6 +185,7 @@ var SlidesCreate = common.Shortcut{
)
var slideIDs []string
+ var slideIssues []map[string]interface{}
for i, slideXML := range slides {
slideData, err := runtime.CallAPITyped(
"POST",
@@ -194,13 +198,24 @@ var SlidesCreate = common.Shortcut{
if err != nil {
return appendSlidesProgressHint(err, fmt.Sprintf("adding slide %d/%d failed; presentation %s was created, %d slide(s) added before failure", i+1, len(slides), presentationID, i))
}
- if sid := common.GetString(slideData, "slide_id"); sid != "" {
+ sid := common.GetString(slideData, "slide_id")
+ if sid != "" {
slideIDs = append(slideIDs, sid)
}
+ if issues, ok := slideData["issues"]; ok {
+ slideIssues = append(slideIssues, map[string]interface{}{
+ "slide_index": i + 1,
+ "slide_id": sid,
+ "issues": issues,
+ })
+ }
}
result["slide_ids"] = slideIDs
result["slides_added"] = len(slideIDs)
+ if len(slideIssues) > 0 {
+ result["slide_issues"] = slideIssues
+ }
}
}
diff --git a/shortcuts/slides/slides_create_test.go b/shortcuts/slides/slides_create_test.go
index a5ba64cdf..a2d36b414 100644
--- a/shortcuts/slides/slides_create_test.go
+++ b/shortcuts/slides/slides_create_test.go
@@ -351,6 +351,56 @@ func TestSlidesCreateWithSlides(t *testing.T) {
}
}
+func TestSlidesCreatePreservesSchemaIssues(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/slides_ai/v1/xml_presentations",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "xml_presentation_id": "pres_issues",
+ "issues": "presentation schema issue",
+ },
+ },
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/slides_ai/v1/xml_presentations/pres_issues/slide",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "slide_id": "slide_001",
+ "issues": "slide schema issue",
+ },
+ },
+ })
+
+ err := runSlidesCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--slides", `[""]`,
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ data := decodeSlidesCreateEnvelope(t, stdout)
+ if data["issues"] != "presentation schema issue" {
+ t.Fatalf("issues = %v, want presentation schema issue", data["issues"])
+ }
+ slideIssues, ok := data["slide_issues"].([]interface{})
+ if !ok || len(slideIssues) != 1 {
+ t.Fatalf("slide_issues = %#v, want one entry", data["slide_issues"])
+ }
+ issue, _ := slideIssues[0].(map[string]interface{})
+ if issue["slide_index"] != float64(1) || issue["slide_id"] != "slide_001" || issue["issues"] != "slide schema issue" {
+ t.Fatalf("slide_issues[0] = %#v", issue)
+ }
+}
+
// TestSlidesCreateWithSlidesPartialFailure verifies error reporting when a slide fails to create.
func TestSlidesCreateWithSlidesPartialFailure(t *testing.T) {
t.Parallel()
diff --git a/shortcuts/slides/slides_history.go b/shortcuts/slides/slides_history.go
new file mode 100644
index 000000000..455f4ab98
--- /dev/null
+++ b/shortcuts/slides/slides_history.go
@@ -0,0 +1,290 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package slides
+
+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 slidesHistoryListSpec struct {
+ PageSize int
+ PageToken string
+}
+
+type slidesHistoryRevertSpec struct {
+ HistoryVersionID string
+}
+
+type slidesHistoryRevertStatusSpec struct {
+ TaskID string
+}
+
+func parseSlidesHistoryPresentation(runtime *common.RuntimeContext) (presentationRef, error) {
+ ref, err := parsePresentationRef(runtime.Str("presentation"))
+ if err != nil {
+ return presentationRef{}, err
+ }
+ if ref.Kind == "wiki" {
+ if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
+ return presentationRef{}, err
+ }
+ }
+ return ref, nil
+}
+
+func validateSlidesHistoryPageSize(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 validateSlidesHistoryVersionID(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 slides +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 slides +history-list").WithParam("--history-version-id")
+ }
+ return nil
+}
+
+func slidesHistoryListParams(spec slidesHistoryListSpec) map[string]interface{} {
+ params := map[string]interface{}{
+ "page_size": spec.PageSize,
+ }
+ if spec.PageToken != "" {
+ params["page_token"] = spec.PageToken
+ }
+ return params
+}
+
+func slidesHistoryRevertBody(spec slidesHistoryRevertSpec) map[string]interface{} {
+ return map[string]interface{}{
+ "history_version_id": spec.HistoryVersionID,
+ }
+}
+
+func slidesHistoryStatusParams(spec slidesHistoryRevertStatusSpec) map[string]interface{} {
+ return map[string]interface{}{
+ "task_id": spec.TaskID,
+ }
+}
+
+func slidesHistoryAPIPath(presentationID, suffix string) string {
+ return fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/%s", validate.EncodePathSegment(presentationID), suffix)
+}
+
+func newSlidesHistoryDryRun(ref presentationRef, desc string) (*common.DryRunAPI, string) {
+ dry := common.NewDryRunAPI()
+ presentationID := ref.Token
+ if ref.Kind == "wiki" {
+ presentationID = ""
+ dry.Desc("2-step orchestration: resolve wiki then " + desc).
+ GET("/open-apis/wiki/v2/spaces/get_node").
+ Desc("[1] Resolve wiki node to slides presentation").
+ Params(map[string]interface{}{"token": ref.Token})
+ } else {
+ dry.Desc("OpenAPI: " + desc)
+ }
+ return dry, presentationID
+}
+
+// SlidesHistoryList lists history versions of a Slides XML presentation.
+var SlidesHistoryList = common.Shortcut{
+ Service: "slides",
+ Command: "+history-list",
+ Description: "List Slides presentation history versions",
+ Risk: "read",
+ Scopes: []string{"slides:presentation:read"},
+ ConditionalScopes: []string{"wiki:node:read"},
+ AuthTypes: []string{"user", "bot"},
+ Flags: []common.Flag{
+ {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", 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 := parseSlidesHistoryPresentation(runtime); err != nil {
+ return err
+ }
+ return validateSlidesHistoryPageSize(runtime.Int("page-size"))
+ },
+ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ ref, err := parsePresentationRef(runtime.Str("presentation"))
+ if err != nil {
+ return common.NewDryRunAPI().Set("error", err.Error())
+ }
+ spec := slidesHistoryListSpec{
+ PageSize: runtime.Int("page-size"),
+ PageToken: strings.TrimSpace(runtime.Str("page-token")),
+ }
+ dry, presentationID := newSlidesHistoryDryRun(ref, "list Slides history versions")
+ return dry.
+ GET(slidesHistoryAPIPath(presentationID, "histories")).
+ Params(slidesHistoryListParams(spec)).
+ Set("xml_presentation_id", presentationID)
+ },
+ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ ref, err := parsePresentationRef(runtime.Str("presentation"))
+ if err != nil {
+ return err
+ }
+ presentationID, err := resolvePresentationID(runtime, ref)
+ if err != nil {
+ return err
+ }
+ spec := slidesHistoryListSpec{
+ PageSize: runtime.Int("page-size"),
+ PageToken: strings.TrimSpace(runtime.Str("page-token")),
+ }
+
+ data, err := runtime.CallAPITyped(
+ http.MethodGet,
+ slidesHistoryAPIPath(presentationID, "histories"),
+ slidesHistoryListParams(spec),
+ nil,
+ )
+ if err != nil {
+ return err
+ }
+ runtime.OutRaw(data, nil)
+ return nil
+ },
+}
+
+// SlidesHistoryRevert reverts a Slides XML presentation to a history version.
+var SlidesHistoryRevert = common.Shortcut{
+ Service: "slides",
+ Command: "+history-revert",
+ Description: "Revert a Slides presentation to a historical version",
+ Risk: "write",
+ Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
+ ConditionalScopes: []string{"wiki:node:read"},
+ AuthTypes: []string{"user", "bot"},
+ Flags: []common.Flag{
+ {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
+ {Name: "history-version-id", Desc: "history_version_id from slides +history-list to revert to", Required: true},
+ },
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ if _, err := parseSlidesHistoryPresentation(runtime); err != nil {
+ return err
+ }
+ if err := validateSlidesHistoryVersionID(runtime.Str("history-version-id")); err != nil {
+ return err
+ }
+ return nil
+ },
+ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ ref, err := parsePresentationRef(runtime.Str("presentation"))
+ if err != nil {
+ return common.NewDryRunAPI().Set("error", err.Error())
+ }
+ spec := slidesHistoryRevertSpec{
+ HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
+ }
+ dry, presentationID := newSlidesHistoryDryRun(ref, "revert Slides history")
+ return dry.
+ POST(slidesHistoryAPIPath(presentationID, "history/revert")).
+ Body(slidesHistoryRevertBody(spec)).
+ Set("xml_presentation_id", presentationID)
+ },
+ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ ref, err := parsePresentationRef(runtime.Str("presentation"))
+ if err != nil {
+ return err
+ }
+ presentationID, err := resolvePresentationID(runtime, ref)
+ if err != nil {
+ return err
+ }
+ spec := slidesHistoryRevertSpec{
+ HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
+ }
+
+ data, err := runtime.CallAPITyped(
+ http.MethodPost,
+ slidesHistoryAPIPath(presentationID, "history/revert"),
+ nil,
+ slidesHistoryRevertBody(spec),
+ )
+ if err != nil {
+ return err
+ }
+ runtime.OutRaw(data, nil)
+ return nil
+ },
+}
+
+// SlidesHistoryRevertStatus gets the status of a Slides history revert task.
+var SlidesHistoryRevertStatus = common.Shortcut{
+ Service: "slides",
+ Command: "+history-revert-status",
+ Description: "Get Slides history revert task status",
+ Risk: "read",
+ Scopes: []string{"slides:presentation:read"},
+ ConditionalScopes: []string{"wiki:node:read"},
+ AuthTypes: []string{"user", "bot"},
+ Flags: []common.Flag{
+ {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
+ {Name: "task-id", Desc: "task_id returned by slides +history-revert", Required: true},
+ },
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ if _, err := parseSlidesHistoryPresentation(runtime); 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, err := parsePresentationRef(runtime.Str("presentation"))
+ if err != nil {
+ return common.NewDryRunAPI().Set("error", err.Error())
+ }
+ spec := slidesHistoryRevertStatusSpec{
+ TaskID: strings.TrimSpace(runtime.Str("task-id")),
+ }
+ dry, presentationID := newSlidesHistoryDryRun(ref, "get Slides history revert status")
+ return dry.
+ GET(slidesHistoryAPIPath(presentationID, "history/revert_status")).
+ Params(slidesHistoryStatusParams(spec)).
+ Set("xml_presentation_id", presentationID)
+ },
+ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ ref, err := parsePresentationRef(runtime.Str("presentation"))
+ if err != nil {
+ return err
+ }
+ presentationID, err := resolvePresentationID(runtime, ref)
+ if err != nil {
+ return err
+ }
+ spec := slidesHistoryRevertStatusSpec{
+ TaskID: strings.TrimSpace(runtime.Str("task-id")),
+ }
+
+ data, err := runtime.CallAPITyped(
+ http.MethodGet,
+ slidesHistoryAPIPath(presentationID, "history/revert_status"),
+ slidesHistoryStatusParams(spec),
+ nil,
+ )
+ if err != nil {
+ return err
+ }
+ runtime.OutRaw(data, nil)
+ return nil
+ },
+}
diff --git a/shortcuts/slides/slides_history_test.go b/shortcuts/slides/slides_history_test.go
new file mode 100644
index 000000000..cf1a08373
--- /dev/null
+++ b/shortcuts/slides/slides_history_test.go
@@ -0,0 +1,443 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package slides
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/url"
+ "reflect"
+ "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 TestSlidesHistoryDeclaredScopes(t *testing.T) {
+ tests := []struct {
+ name string
+ shortcut common.Shortcut
+ wantBase []string
+ wantFull []string
+ }{
+ {
+ name: "list",
+ shortcut: SlidesHistoryList,
+ wantBase: []string{"slides:presentation:read"},
+ wantFull: []string{"slides:presentation:read", "wiki:node:read"},
+ },
+ {
+ name: "revert",
+ shortcut: SlidesHistoryRevert,
+ wantBase: []string{"slides:presentation:update", "slides:presentation:write_only"},
+ wantFull: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
+ },
+ {
+ name: "status",
+ shortcut: SlidesHistoryRevertStatus,
+ wantBase: []string{"slides:presentation:read"},
+ wantFull: []string{"slides:presentation:read", "wiki:node:read"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.shortcut.ScopesForIdentity("user"); !reflect.DeepEqual(got, tt.wantBase) {
+ t.Fatalf("user preflight scopes = %#v, want %#v", got, tt.wantBase)
+ }
+ if got := tt.shortcut.ScopesForIdentity("bot"); !reflect.DeepEqual(got, tt.wantBase) {
+ t.Fatalf("bot preflight scopes = %#v, want %#v", got, tt.wantBase)
+ }
+ if got := tt.shortcut.DeclaredScopesForIdentity("user"); !reflect.DeepEqual(got, tt.wantFull) {
+ t.Fatalf("declared scopes = %#v, want %#v", got, tt.wantFull)
+ }
+ })
+ }
+}
+
+func TestSlidesHistoryValidation(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ shortcut common.Shortcut
+ args []string
+ param string
+ wantCause bool
+ }{
+ {
+ name: "list rejects unsupported presentation input",
+ shortcut: SlidesHistoryList,
+ args: []string{"+history-list", "--presentation", "tmp/wiki/wikcn123", "--as", "bot"},
+ param: "--presentation",
+ },
+ {
+ name: "list rejects invalid page size",
+ shortcut: SlidesHistoryList,
+ args: []string{"+history-list", "--presentation", "presHistory", "--page-size", "0", "--as", "bot"},
+ param: "--page-size",
+ },
+ {
+ name: "revert rejects non-numeric history version id",
+ shortcut: SlidesHistoryRevert,
+ args: []string{"+history-revert", "--presentation", "presHistory", "--history-version-id", "abc", "--as", "bot"},
+ param: "--history-version-id",
+ wantCause: true,
+ },
+ {
+ name: "revert rejects non-positive history version id",
+ shortcut: SlidesHistoryRevert,
+ args: []string{"+history-revert", "--presentation", "presHistory", "--history-version-id", "0", "--as", "bot"},
+ param: "--history-version-id",
+ },
+ {
+ name: "status rejects empty task id",
+ shortcut: SlidesHistoryRevertStatus,
+ args: []string{"+history-revert-status", "--presentation", "presHistory", "--task-id", "", "--as", "bot"},
+ param: "--task-id",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ err := runSlidesShortcut(t, f, stdout, tt.shortcut, tt.args)
+ if err == nil {
+ t.Fatal("expected validation error, got nil")
+ }
+ _, ok := errs.ProblemOf(err)
+ if !ok {
+ t.Fatalf("error is not typed: %T %v", err, 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 TestSlidesHistoryDryRun(t *testing.T) {
+ t.Parallel()
+
+ listCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryList, map[string]string{
+ "presentation": "presHistoryDryRun",
+ "page-size": "5",
+ "page-token": "page_token_1",
+ })
+ listDry := decodeSlidesHistoryDryRun(t, SlidesHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
+ if got, want := listDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/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 := newSlidesHistoryRuntimeCmd(t, SlidesHistoryRevert, map[string]string{
+ "presentation": "presHistoryDryRun",
+ "history-version-id": "42",
+ })
+ revertDry := decodeSlidesHistoryDryRun(t, SlidesHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
+ if got, want := revertDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/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 _, ok := revertDry.API[0].Body["wait_timeout_ms"]; ok {
+ t.Fatal("revert body must not contain wait_timeout_ms")
+ }
+
+ statusCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryRevertStatus, map[string]string{
+ "presentation": "presHistoryDryRun",
+ "task-id": "task_1",
+ })
+ statusDry := decodeSlidesHistoryDryRun(t, SlidesHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
+ if got, want := statusDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/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 TestSlidesHistoryDryRunWithWikiPresentation(t *testing.T) {
+ t.Parallel()
+
+ cmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryList, map[string]string{
+ "presentation": "https://example.feishu.cn/wiki/wikcn123",
+ "page-size": "20",
+ })
+ dry := decodeSlidesHistoryDryRun(t, SlidesHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
+ if len(dry.API) != 2 {
+ t.Fatalf("api calls = %d, want 2: %#v", len(dry.API), dry.API)
+ }
+ if got, want := dry.API[0].URL, "/open-apis/wiki/v2/spaces/get_node"; got != want {
+ t.Fatalf("wiki dry-run URL = %q, want %q", got, want)
+ }
+ if got := dry.API[0].Params["token"]; got != "wikcn123" {
+ t.Fatalf("wiki node parameter mismatch: got %#v, want placeholder node id", got)
+ }
+ if got, want := dry.API[1].URL, "/open-apis/slides_ai/v1/xml_presentations/%3Cresolved_slides_token%3E/histories"; got != want {
+ t.Fatalf("history dry-run URL = %q, want %q", got, want)
+ }
+}
+
+func TestSlidesHistoryExecuteList(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ var capturedQuery url.Values
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/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": "2026-06-22T12:24:45Z",
+ "type": float64(1),
+ "editor_ids": []interface{}{"ou_1"},
+ },
+ },
+ "has_more": true,
+ "page_token": "page_token_2",
+ },
+ },
+ OnMatch: func(req *http.Request) {
+ capturedQuery = req.URL.Query()
+ },
+ })
+
+ err := runSlidesShortcut(t, f, stdout, SlidesHistoryList, []string{
+ "+history-list",
+ "--presentation", "presHistory",
+ "--page-size", "5",
+ "--page-token", "page_token_1",
+ "--as", "bot",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if got := capturedQuery.Get("page_size"); got != "5" {
+ t.Fatalf("page_size query = %q, want 5", got)
+ }
+ if got := capturedQuery.Get("page_token"); got != "page_token_1" {
+ t.Fatalf("page_token query = %q, want page_token_1", got)
+ }
+
+ data := decodeSlidesHistoryEnvelope(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 TestSlidesHistoryExecuteRevert(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/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 := runSlidesShortcut(t, f, stdout, SlidesHistoryRevert, []string{
+ "+history-revert",
+ "--presentation", "presHistory",
+ "--history-version-id", "42",
+ "--as", "bot",
+ })
+ 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 _, ok := body["wait_timeout_ms"]; ok {
+ t.Fatal("revert body must not contain wait_timeout_ms")
+ }
+
+ data := decodeSlidesHistoryEnvelope(t, stdout)
+ if got := data["task_id"]; got != "task_1" {
+ t.Fatalf("task_id = %#v, want task_1", got)
+ }
+}
+
+func TestSlidesHistoryExecuteRevertStatus(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ var capturedQuery url.Values
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/history/revert_status",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "status": "done",
+ "history_version_id": "11",
+ },
+ },
+ OnMatch: func(req *http.Request) {
+ capturedQuery = req.URL.Query()
+ },
+ })
+
+ err := runSlidesShortcut(t, f, stdout, SlidesHistoryRevertStatus, []string{
+ "+history-revert-status",
+ "--presentation", "presHistory",
+ "--task-id", "task_1",
+ "--as", "bot",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if got := capturedQuery.Get("task_id"); got != "task_1" {
+ t.Fatalf("task_id query = %q, want task_1", got)
+ }
+ data := decodeSlidesHistoryEnvelope(t, stdout)
+ if got := data["status"]; got != "done" {
+ t.Fatalf("status = %#v, want done", got)
+ }
+ if got := data["history_version_id"]; got != "11" {
+ t.Fatalf("history_version_id = %#v, want 11", got)
+ }
+}
+
+func TestSlidesHistoryExecuteResolvesWikiPresentation(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/wiki/v2/spaces/get_node",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "node": map[string]interface{}{
+ "obj_type": "slides",
+ "obj_token": "presReal",
+ },
+ },
+ },
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/slides_ai/v1/xml_presentations/presReal/histories",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "entries": []interface{}{},
+ "has_more": false,
+ "page_token": "",
+ },
+ },
+ })
+
+ err := runSlidesShortcut(t, f, stdout, SlidesHistoryList, []string{
+ "+history-list",
+ "--presentation", "https://example.feishu.cn/wiki/wikcn123",
+ "--as", "bot",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ data := decodeSlidesHistoryEnvelope(t, stdout)
+ if got := data["has_more"]; got != false {
+ t.Fatalf("has_more = %#v, want false", got)
+ }
+}
+
+type slidesHistoryDryRunOutput struct {
+ API []struct {
+ Method string `json:"method"`
+ URL string `json:"url"`
+ Params map[string]interface{} `json:"params"`
+ Body map[string]interface{} `json:"body"`
+ } `json:"api"`
+}
+
+func newSlidesHistoryRuntimeCmd(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 decodeSlidesHistoryDryRun(t *testing.T, dry *common.DryRunAPI) slidesHistoryDryRunOutput {
+ t.Helper()
+
+ raw, err := json.Marshal(dry)
+ if err != nil {
+ t.Fatalf("marshal dry-run: %v", err)
+ }
+ var out slidesHistoryDryRunOutput
+ if err := json.Unmarshal(raw, &out); err != nil {
+ t.Fatalf("decode dry-run: %v\nraw=%s", err, raw)
+ }
+ return out
+}
+
+func decodeSlidesHistoryEnvelope(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
+}
diff --git a/shortcuts/slides/slides_replace_pages.go b/shortcuts/slides/slides_replace_pages.go
index a2ee77163..50278e589 100644
--- a/shortcuts/slides/slides_replace_pages.go
+++ b/shortcuts/slides/slides_replace_pages.go
@@ -139,6 +139,7 @@ type replacePageResult struct {
NewSlideID string
Status string
Error string
+ Issues interface{}
RevisionID *int
}
@@ -330,6 +331,9 @@ func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item
return result, err
}
result.NewSlideID = newSlideID
+ if issues, ok := createData["issues"]; ok {
+ result.Issues = issues
+ }
if rev, ok := revisionFromData(createData); ok {
revisionID = rev
result.RevisionID = &rev
@@ -389,6 +393,9 @@ func replacePageResultsOutput(results []replacePageResult) []map[string]interfac
if result.Error != "" {
m["error"] = result.Error
}
+ if result.Issues != nil {
+ m["issues"] = result.Issues
+ }
if result.RevisionID != nil {
m["revision_id"] = *result.RevisionID
}
diff --git a/shortcuts/slides/slides_replace_pages_test.go b/shortcuts/slides/slides_replace_pages_test.go
index e62169d8e..68b3d4207 100644
--- a/shortcuts/slides/slides_replace_pages_test.go
+++ b/shortcuts/slides/slides_replace_pages_test.go
@@ -42,7 +42,7 @@ func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
- "data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
+ "data": map[string]interface{}{"slide_id": "new2", "revision_id": 11, "issues": "slide schema issue"},
},
OnMatch: func(req *http.Request) {
requestOrder = append(requestOrder, req.Method)
@@ -123,6 +123,9 @@ func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
if first["old_slide_id"] != "old2" || first["new_slide_id"] != "new2" || first["status"] != "replaced" {
t.Fatalf("result = %#v", first)
}
+ if first["issues"] != "slide schema issue" {
+ t.Fatalf("result.issues = %v, want slide schema issue", first["issues"])
+ }
}
func TestReplacePagesContinueOnErrorReturnsPartialFailure(t *testing.T) {
diff --git a/shortcuts/slides/slides_screenshot.go b/shortcuts/slides/slides_screenshot.go
index b95bd727a..a392da0e2 100644
--- a/shortcuts/slides/slides_screenshot.go
+++ b/shortcuts/slides/slides_screenshot.go
@@ -37,15 +37,13 @@ var SlidesScreenshot = common.Shortcut{
Command: "+screenshot",
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
Risk: "read",
- Scopes: []string{},
- // The screenshot API is allowlist-gated for only a few apps, so do not
- // advertise/preflight its scope. Let the API fail and let callers degrade.
+ Scopes: []string{"slides:presentation:screenshot"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
- {Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides; max 10 pages per request)"},
+ {Name: "slide-id", Type: "string_slice", Desc: "slide page identifier (repeat or comma-separated for multiple slides; max 10 pages per request)"},
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides; max 10 pages per request)"},
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
@@ -57,7 +55,7 @@ var SlidesScreenshot = common.Shortcut{
if strings.TrimSpace(runtime.Str("content")) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
- if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
+ if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
@@ -73,7 +71,7 @@ var SlidesScreenshot = common.Shortcut{
return err
}
}
- slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
+ slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return err
@@ -98,7 +96,7 @@ var SlidesScreenshot = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
- slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
+ slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
@@ -148,7 +146,7 @@ var SlidesScreenshot = common.Shortcut{
return err
}
- slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
+ slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return err
@@ -200,7 +198,7 @@ func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
if strings.TrimSpace(content) == "" {
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
}
- if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
+ if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
@@ -219,7 +217,7 @@ func executeRenderScreenshot(runtime *common.RuntimeContext) error {
if strings.TrimSpace(content) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
- if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
+ if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
diff --git a/shortcuts/slides/slides_screenshot_test.go b/shortcuts/slides/slides_screenshot_test.go
index c573171b9..64a26cf27 100644
--- a/shortcuts/slides/slides_screenshot_test.go
+++ b/shortcuts/slides/slides_screenshot_test.go
@@ -8,6 +8,7 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "reflect"
"strings"
"testing"
@@ -17,23 +18,19 @@ import (
)
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
- if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
- t.Fatalf("user preflight scopes = %#v, want empty", got)
+ base := []string{"slides:presentation:screenshot"}
+ if got := SlidesScreenshot.ScopesForIdentity("user"); !reflect.DeepEqual(got, base) {
+ t.Fatalf("user preflight scopes = %#v, want %#v", got, base)
}
- if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
- t.Fatalf("bot preflight scopes = %#v, want empty", got)
+ if got := SlidesScreenshot.ScopesForIdentity("bot"); !reflect.DeepEqual(got, base) {
+ t.Fatalf("bot preflight scopes = %#v, want %#v", got, base)
}
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
- want := []string{"wiki:node:read"}
- if len(got) != len(want) || got[0] != want[0] {
+ want := []string{"slides:presentation:screenshot", "wiki:node:read"}
+ if !reflect.DeepEqual(got, want) {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
- for _, scope := range got {
- if scope == "slides:presentation:screenshot" {
- t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
- }
- }
}
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
@@ -188,6 +185,139 @@ func TestSlidesScreenshotListBySlideNumber(t *testing.T) {
}
}
+func TestSlidesScreenshotListBySlideIDCSV(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ dir := t.TempDir()
+ withSlidesTestWorkingDir(t, dir)
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "slide_images": []map[string]interface{}{
+ {
+ "slide_id": "slide_1",
+ "format": 1,
+ "data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
+ },
+ {
+ "slide_id": "slide_2",
+ "format": 1,
+ "data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
+ },
+ },
+ },
+ },
+ }
+ reg.Register(stub)
+
+ err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
+ "+screenshot",
+ "--presentation", "pres_abc",
+ "--slide-id", "slide_1,slide_2",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var body struct {
+ SlideIDs []string `json:"slide_ids"`
+ }
+ if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
+ t.Fatalf("decode request body: %v", err)
+ }
+ if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
+ t.Fatalf("slide_ids = %#v, want [slide_1 slide_2]", body.SlideIDs)
+ }
+
+ path1 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_1.png")
+ if _, err := os.ReadFile(path1); err != nil {
+ t.Fatalf("read first CSV slide screenshot: %v", err)
+ }
+ path2 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_2.png")
+ if _, err := os.ReadFile(path2); err != nil {
+ t.Fatalf("read second CSV slide screenshot: %v", err)
+ }
+}
+
+func TestSlidesScreenshotListBySlideIDCSVDeduplicatesAndTrims(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ dir := t.TempDir()
+ withSlidesTestWorkingDir(t, dir)
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "slide_images": []map[string]interface{}{
+ {
+ "slide_id": "slide_1",
+ "format": 1,
+ "data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
+ },
+ {
+ "slide_id": "slide_2",
+ "format": 1,
+ "data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
+ },
+ },
+ },
+ },
+ }
+ reg.Register(stub)
+
+ // CSV with a duplicate and blank segments should normalize the same way
+ // normalizeSlideIDs already does for repeated --slide-id flags.
+ err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
+ "+screenshot",
+ "--presentation", "pres_abc",
+ "--slide-id", "slide_1, slide_2,slide_1,",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var body struct {
+ SlideIDs []string `json:"slide_ids"`
+ }
+ if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
+ t.Fatalf("decode request body: %v", err)
+ }
+ if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
+ t.Fatalf("slide_ids = %#v, want deduplicated [slide_1 slide_2]", body.SlideIDs)
+ }
+}
+
+func TestSlidesScreenshotListRejectsMoreThanTenSlideIDsCSV(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+
+ err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
+ "+screenshot",
+ "--presentation", "pres_abc",
+ "--slide-id", "s1,s2,s3,s4,s5,s6,s7,s8,s9,s10,s11",
+ "--as", "user",
+ })
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok {
+ t.Fatalf("error = %v, want typed validation error", err)
+ }
+ if problem.Hint != "request at most 10 pages at a time" {
+ t.Fatalf("hint = %q, want max 10 pages guidance", problem.Hint)
+ }
+}
+
func TestSlidesScreenshotAvoidsOverwritingExistingFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
@@ -390,6 +520,27 @@ func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) {
}
}
+func TestSlidesScreenshotRenderRejectsSlideNumberSelector(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+ f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
+
+ // Exercises the --slide-number-only side of the --content conflict check
+ // (TestSlidesScreenshotRenderRejectsSlideSelectors above only covers the
+ // --slide-id side of that same `||` condition).
+ err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
+ "+screenshot",
+ "--content", ``,
+ "--slide-number", "1",
+ "--as", "user",
+ })
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") {
+ t.Fatalf("error = %v, want content/slide selector conflict", err)
+ }
+}
+
func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
diff --git a/shortcuts/task/shortcuts.go b/shortcuts/task/shortcuts.go
index 7de989ca5..4112ebd84 100644
--- a/shortcuts/task/shortcuts.go
+++ b/shortcuts/task/shortcuts.go
@@ -10,6 +10,7 @@ import (
"io"
"net/http"
"net/url"
+ "regexp"
"strings"
"time"
@@ -100,6 +101,40 @@ func extractTaskGuid(input string) string {
return extractTasklistGuid(input)
}
+var taskDisplayNumberPattern = regexp.MustCompile(`^t[0-9]+$`)
+
+func parseTaskGUID(input string) (string, error) {
+ input = strings.TrimSpace(input)
+ invalid := func(format string, args ...interface{}) *errs.ValidationError {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...).
+ WithParam("--task-id").
+ WithHint("provide the Task OpenAPI GUID or a task applink containing guid=")
+ }
+
+ if input == "" {
+ return "", invalid("task ID is empty")
+ }
+
+ lowerInput := strings.ToLower(input)
+ if strings.HasPrefix(lowerInput, "http://") || strings.HasPrefix(lowerInput, "https://") {
+ u, err := url.Parse(input)
+ if err != nil {
+ return "", invalid("invalid task applink: %v", err).WithCause(err)
+ }
+ guid := strings.TrimSpace(u.Query().Get("guid"))
+ if guid == "" {
+ return "", invalid("task applink is missing a non-empty guid query parameter")
+ }
+ return guid, nil
+ }
+
+ if taskDisplayNumberPattern.MatchString(input) {
+ return "", invalid("task display number %q is not a Task OpenAPI GUID", input)
+ }
+
+ return input, nil
+}
+
func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := make(map[string]interface{})
diff --git a/shortcuts/task/shortcuts_test.go b/shortcuts/task/shortcuts_test.go
index 0b4a65420..29f525181 100644
--- a/shortcuts/task/shortcuts_test.go
+++ b/shortcuts/task/shortcuts_test.go
@@ -4,8 +4,11 @@
package task
import (
+ "errors"
+ "net/url"
"testing"
+ "github.com/larksuite/cli/errs"
"github.com/smartystreets/goconvey/convey"
)
@@ -15,3 +18,80 @@ func TestShortcutsRegistration(t *testing.T) {
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
})
}
+
+func TestParseTaskGUID(t *testing.T) {
+ t.Run("accepts GUIDs and task applinks", func(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {name: "opaque GUID", input: "task-guid-123", want: "task-guid-123"},
+ {name: "trimmed GUID", input: " task-guid-123 ", want: "task-guid-123"},
+ {
+ name: "task applink",
+ input: "https://applink.larksuite.com/client/todo/detail?guid=task-guid-123",
+ want: "task-guid-123",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := parseTaskGUID(tt.input)
+ if err != nil {
+ t.Fatalf("parseTaskGUID(%q) error = %v", tt.input, err)
+ }
+ if got != tt.want {
+ t.Fatalf("parseTaskGUID(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+ })
+
+ t.Run("rejects unusable task identifiers", func(t *testing.T) {
+ for _, input := range []string{
+ "",
+ "https://applink.larksuite.com/client/todo/detail",
+ "https://%",
+ "t12345",
+ } {
+ t.Run(input, func(t *testing.T) {
+ _, err := parseTaskGUID(input)
+ if err == nil {
+ t.Fatalf("parseTaskGUID(%q) error = nil, want typed validation error", input)
+ }
+
+ problem, ok := errs.ProblemOf(err)
+ if !ok {
+ t.Fatalf("parseTaskGUID(%q) error type = %T, want typed error", input, err)
+ }
+ if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
+ }
+ if problem.Hint == "" {
+ t.Fatal("problem hint is empty")
+ }
+
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("error type = %T, want *errs.ValidationError", err)
+ }
+ if validationErr.Param != "--task-id" {
+ t.Fatalf("param = %q, want %q", validationErr.Param, "--task-id")
+ }
+ })
+ }
+ })
+
+ t.Run("preserves applink parse cause", func(t *testing.T) {
+ _, err := parseTaskGUID("https://%")
+ if err == nil {
+ t.Fatal("parseTaskGUID() error = nil, want URL parse error")
+ }
+
+ var urlErr *url.Error
+ if !errors.As(err, &urlErr) {
+ t.Fatalf("error chain = %T %v, want *url.Error cause", err, err)
+ }
+ })
+}
diff --git a/shortcuts/task/task_complete.go b/shortcuts/task/task_complete.go
index 8f6d81951..9b4688657 100644
--- a/shortcuts/task/task_complete.go
+++ b/shortcuts/task/task_complete.go
@@ -25,45 +25,59 @@ var CompleteTask = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
- {Name: "task-id", Desc: "task id", Required: true},
+ {Name: "task-id", Desc: "task GUID or task applink URL", Required: true},
+ },
+
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ _, err := parseTaskGUID(runtime.Str("task-id"))
+ return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCompleteBody()
- taskId := url.PathEscape(runtime.Str("task-id"))
+ taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
+ if err != nil {
+ return common.NewDryRunAPI().Set("error", err.Error())
+ }
+ taskID := url.PathEscape(taskGUID)
return common.NewDryRunAPI().
- GET("/open-apis/task/v2/tasks/" + taskId).
+ GET("/open-apis/task/v2/tasks/" + taskID).
Desc("get current task status").
Params(map[string]interface{}{"user_id_type": "open_id"}).
- PATCH("/open-apis/task/v2/tasks/" + taskId).
+ PATCH("/open-apis/task/v2/tasks/" + taskID).
Desc("complete task if not completed").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
- taskId := url.PathEscape(runtime.Str("task-id"))
+ taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
+ if err != nil {
+ return err
+ }
+ taskID := url.PathEscape(taskGUID)
params := map[string]interface{}{"user_id_type": "open_id"}
var data map[string]interface{}
// 1. Get current task status
- getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
+ getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskID, params, nil)
if err != nil {
return err
}
taskData, _ := getData["task"].(map[string]interface{})
completedAtStr, _ := taskData["completed_at"].(string)
+ alreadyCompleted := completedAtStr != "" && completedAtStr != "0"
// 2. If already completed, directly return success
- if completedAtStr != "" && completedAtStr != "0" {
+ if alreadyCompleted {
data = getData
} else {
// 3. Complete the task
body := buildCompleteBody()
- data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
+ data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskID, params, body)
if err != nil {
return err
}
@@ -73,11 +87,19 @@ var CompleteTask = common.Shortcut{
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
+ completedAt, _ := task["completed_at"].(string)
+ status := "todo"
+ if completedAt != "" && completedAt != "0" {
+ status = "done"
+ }
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
- "guid": guid,
- "url": urlVal,
+ "guid": guid,
+ "url": urlVal,
+ "status": status,
+ "completed_at": completedAt,
+ "already_completed": alreadyCompleted,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
diff --git a/shortcuts/task/task_complete_test.go b/shortcuts/task/task_complete_test.go
index 36f0ef928..7394324bc 100644
--- a/shortcuts/task/task_complete_test.go
+++ b/shortcuts/task/task_complete_test.go
@@ -4,9 +4,12 @@
package task
import (
+ "encoding/json"
+ "errors"
"strings"
"testing"
+ "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -45,6 +48,9 @@ func TestCompleteTask(t *testing.T) {
formatFlag: "json",
expectedOutput: []string{
`"guid": "task-789"`,
+ `"status": "done"`,
+ `"completed_at": "1775174400000"`,
+ `"already_completed": false`,
},
},
}
@@ -109,3 +115,98 @@ func TestCompleteTask(t *testing.T) {
})
}
}
+
+func TestTaskCompleteAcceptsTaskApplink(t *testing.T) {
+ f, stdout, _, reg := taskShortcutTestFactory(t)
+ warmTenantToken(t, f, reg)
+
+ for _, method := range []string{"GET", "PATCH"} {
+ reg.Register(&httpmock.Stub{
+ Method: method,
+ URL: "/open-apis/task/v2/tasks/task-guid-applink",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "success",
+ "data": map[string]interface{}{
+ "task": map[string]interface{}{
+ "guid": "task-guid-applink",
+ "summary": "Applink task",
+ "completed_at": map[string]string{"GET": "0", "PATCH": "1775174400000"}[method],
+ "url": "https://example.com/task-guid-applink",
+ },
+ },
+ },
+ })
+ }
+
+ err := runMountedTaskShortcut(t, CompleteTask, []string{
+ "+complete",
+ "--task-id", "https://applink.larksuite.com/client/todo/detail?guid=task-guid-applink",
+ "--format", "json",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("CompleteTask error = %v", err)
+ }
+ reg.Verify(t)
+ if !strings.Contains(stdout.String(), `"guid": "task-guid-applink"`) {
+ t.Fatalf("output = %s, want normalized task GUID", stdout.String())
+ }
+}
+
+func TestTaskCompleteAlreadyCompletedReturnsServerState(t *testing.T) {
+ f, stdout, _, reg := taskShortcutTestFactory(t)
+ warmTenantToken(t, f, reg)
+
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/task/v2/tasks/task-guid-done",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "success",
+ "data": map[string]interface{}{
+ "task": map[string]interface{}{
+ "guid": "task-guid-done",
+ "summary": "Already done",
+ "completed_at": "1775174400000",
+ "url": "https://example.com/task-guid-done",
+ },
+ },
+ },
+ })
+
+ err := runMountedTaskShortcut(t, CompleteTask, []string{
+ "+complete", "--task-id", "task-guid-done", "--format", "json", "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("CompleteTask error = %v", err)
+ }
+ reg.Verify(t)
+
+ var envelope map[string]interface{}
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode output: %v\n%s", err, stdout.String())
+ }
+ data, _ := envelope["data"].(map[string]interface{})
+ if data["status"] != "done" || data["completed_at"] != "1775174400000" || data["already_completed"] != true {
+ t.Fatalf("completion state = %#v, want done/already_completed server state", data)
+ }
+}
+
+func TestTaskCompleteRejectsDisplayNumberBeforeRead(t *testing.T) {
+ f, stdout, _, reg := taskShortcutTestFactory(t)
+ warmTenantToken(t, f, reg)
+
+ err := runMountedTaskShortcut(t, CompleteTask, []string{
+ "+complete", "--task-id", "t12345", "--format", "json", "--as", "bot",
+ }, f, stdout)
+ if err == nil {
+ t.Fatal("CompleteTask error = nil, want invalid task ID error")
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
+ }
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
+ t.Fatalf("error param = %#v, want --task-id", validationErr)
+ }
+}
diff --git a/shortcuts/task/task_query_helpers.go b/shortcuts/task/task_query_helpers.go
index affdd1d89..eba2906ef 100644
--- a/shortcuts/task/task_query_helpers.go
+++ b/shortcuts/task/task_query_helpers.go
@@ -24,6 +24,14 @@ func splitAndTrimCSV(input string) []string {
return out
}
+func buildSearchPageParams(pageToken string) map[string]interface{} {
+ params := map[string]interface{}{}
+ if pageToken != "" {
+ params["page_token"] = pageToken
+ }
+ return params
+}
+
func parseTimeRangeMillis(input string) (string, string, error) {
if strings.TrimSpace(input) == "" {
return "", "", nil
diff --git a/shortcuts/task/task_query_helpers_test.go b/shortcuts/task/task_query_helpers_test.go
index 50a199860..09e00ae9d 100644
--- a/shortcuts/task/task_query_helpers_test.go
+++ b/shortcuts/task/task_query_helpers_test.go
@@ -37,6 +37,31 @@ func TestSplitAndTrimCSV(t *testing.T) {
}
}
+func TestBuildSearchPageParams(t *testing.T) {
+ tests := []struct {
+ name string
+ pageToken string
+ wantToken string
+ wantKey bool
+ }{
+ {name: "first page omits token"},
+ {name: "subsequent page includes token", pageToken: "pt_123", wantToken: "pt_123", wantKey: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ params := buildSearchPageParams(tt.pageToken)
+ got, present := params["page_token"]
+ if present != tt.wantKey {
+ t.Fatalf("page_token present = %v, want %v; params = %#v", present, tt.wantKey, params)
+ }
+ if tt.wantKey && got != tt.wantToken {
+ t.Fatalf("page_token = %v, want %q", got, tt.wantToken)
+ }
+ })
+ }
+}
+
func TestOutputTaskSummary(t *testing.T) {
tests := []struct {
name string
diff --git a/shortcuts/task/task_search.go b/shortcuts/task/task_search.go
index 6d22b9efb..e017f6ba1 100644
--- a/shortcuts/task/task_search.go
+++ b/shortcuts/task/task_search.go
@@ -44,8 +44,10 @@ var SearchTask = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
+ params := buildSearchPageParams(runtime.Str("page-token"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/search").
+ Params(params).
Body(body).
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
},
@@ -74,9 +76,9 @@ var SearchTask = common.Shortcut{
var lastPageToken string
var lastHasMore bool
var notice string
- currentBody := body
+ params := buildSearchPageParams(runtime.Str("page-token"))
for page := 0; page < pageLimit; page++ {
- data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
+ data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", params, body)
if err != nil {
return err
}
@@ -90,7 +92,7 @@ var SearchTask = common.Shortcut{
if !lastHasMore || lastPageToken == "" {
break
}
- currentBody["page_token"] = lastPageToken
+ params["page_token"] = lastPageToken
}
enriched := make([]map[string]interface{}, 0, len(rawItems))
@@ -183,9 +185,6 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
if len(filter) > 0 {
body["filter"] = filter
}
- if pageToken := runtime.Str("page-token"); pageToken != "" {
- body["page_token"] = pageToken
- }
return body, nil
}
diff --git a/shortcuts/task/task_search_pagination_test.go b/shortcuts/task/task_search_pagination_test.go
new file mode 100644
index 000000000..dca34bc91
--- /dev/null
+++ b/shortcuts/task/task_search_pagination_test.go
@@ -0,0 +1,129 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package task
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+func TestSearchPaginationUsesQueryToken(t *testing.T) {
+ tests := []struct {
+ name string
+ shortcut common.Shortcut
+ command string
+ url string
+ }{
+ {
+ name: "tasks",
+ shortcut: SearchTask,
+ command: "+search",
+ url: "/open-apis/task/v2/tasks/search",
+ },
+ {
+ name: "tasklists",
+ shortcut: SearchTasklist,
+ command: "+tasklist-search",
+ url: "/open-apis/task/v2/tasklists/search",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ f, stdout, _, reg := taskShortcutTestFactory(t)
+ warmTenantToken(t, f, reg)
+
+ var pageTokens []string
+ reg.Register(searchPaginationStub(t, tt.url, "next_pt", true, &pageTokens))
+ reg.Register(searchPaginationStub(t, tt.url, "", false, &pageTokens))
+
+ shortcut := tt.shortcut
+ shortcut.AuthTypes = []string{"bot", "user"}
+ err := runMountedTaskShortcut(t, shortcut, []string{
+ tt.command,
+ "--query", "pagination",
+ "--page-token", "initial_pt",
+ "--page-limit", "2",
+ "--as", "bot",
+ "--format", "json",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("search command failed: %v", err)
+ }
+
+ want := []string{"initial_pt", "next_pt"}
+ if !reflect.DeepEqual(pageTokens, want) {
+ t.Fatalf("search page tokens = %#v, want %#v", pageTokens, want)
+ }
+ })
+ }
+}
+
+func assertSearchDryRunPageToken(t *testing.T, preview *common.DryRunAPI, want string) {
+ t.Helper()
+
+ data, err := preview.MarshalJSON()
+ if err != nil {
+ t.Fatalf("marshal search dry-run preview: %v", err)
+ }
+ var envelope struct {
+ API []struct {
+ Params map[string]interface{} `json:"params"`
+ Body map[string]interface{} `json:"body"`
+ } `json:"api"`
+ }
+ if err := json.Unmarshal(data, &envelope); err != nil {
+ t.Fatalf("decode search dry-run preview: %v", err)
+ }
+ if len(envelope.API) != 1 {
+ t.Fatalf("search dry-run API call count = %d, want 1; preview = %s", len(envelope.API), data)
+ }
+ call := envelope.API[0]
+ if got, _ := call.Params["page_token"].(string); got != want {
+ t.Fatalf("search dry-run params.page_token = %q, want %q; preview = %s", got, want, data)
+ }
+ if _, present := call.Body["page_token"]; present {
+ t.Fatalf("search dry-run body unexpectedly contains page_token; preview = %s", data)
+ }
+}
+
+func searchPaginationStub(t *testing.T, endpoint, responseToken string, hasMore bool, capturedTokens *[]string) *httpmock.Stub {
+ t.Helper()
+ return &httpmock.Stub{
+ Method: http.MethodPost,
+ URL: endpoint,
+ OnMatch: func(req *http.Request) {
+ *capturedTokens = append(*capturedTokens, req.URL.Query().Get("page_token"))
+
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ t.Errorf("read search request body: %v", err)
+ return
+ }
+ var payload map[string]interface{}
+ if err := json.Unmarshal(body, &payload); err != nil {
+ t.Errorf("decode search request body: %v", err)
+ return
+ }
+ if _, present := payload["page_token"]; present {
+ t.Errorf("search request body unexpectedly contains page_token: %s", body)
+ }
+ },
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "success",
+ "data": map[string]interface{}{
+ "has_more": hasMore,
+ "page_token": responseToken,
+ "items": []interface{}{},
+ },
+ },
+ }
+}
diff --git a/shortcuts/task/task_search_test.go b/shortcuts/task/task_search_test.go
index 9ae559116..7534c13e5 100644
--- a/shortcuts/task/task_search_test.go
+++ b/shortcuts/task/task_search_test.go
@@ -37,9 +37,12 @@ func TestBuildTaskSearchBody(t *testing.T) {
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
dueTime := filter["due_time"].(map[string]interface{})
- if body["query"] != "release" || body["page_token"] != "pt_123" {
+ if body["query"] != "release" {
t.Fatalf("unexpected body: %#v", body)
}
+ if _, present := body["page_token"]; present {
+ t.Fatalf("body unexpectedly contains page_token: %#v", body)
+ }
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
t.Fatalf("unexpected filter: %#v", filter)
}
@@ -104,9 +107,10 @@ func TestBuildTaskSearchBody(t *testing.T) {
func TestSearchTask_DryRun(t *testing.T) {
tests := []struct {
- name string
- setup func(*cobra.Command)
- wantParts []string
+ name string
+ setup func(*cobra.Command)
+ wantPageToken string
+ wantParts []string
}{
{
name: "valid dry run",
@@ -114,7 +118,8 @@ func TestSearchTask_DryRun(t *testing.T) {
_ = cmd.Flags().Set("query", "demo")
_ = cmd.Flags().Set("page-token", "pt_demo")
},
- wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
+ wantPageToken: "pt_demo",
+ wantParts: []string{`"query":"demo"`},
},
{
name: "dry run error on invalid due",
@@ -143,7 +148,11 @@ func TestSearchTask_DryRun(t *testing.T) {
t.Fatalf("Validate() error = %v", err)
}
}
- out := SearchTask.DryRun(nil, runtime).Format()
+ preview := SearchTask.DryRun(nil, runtime)
+ if tt.wantPageToken != "" {
+ assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
+ }
+ out := preview.Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
diff --git a/shortcuts/task/task_tasklist_search.go b/shortcuts/task/task_tasklist_search.go
index e3ca6899f..e9291dcb2 100644
--- a/shortcuts/task/task_tasklist_search.go
+++ b/shortcuts/task/task_tasklist_search.go
@@ -41,8 +41,10 @@ var SearchTasklist = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
+ params := buildSearchPageParams(runtime.Str("page-token"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasklists/search").
+ Params(params).
Body(body).
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
},
@@ -71,9 +73,9 @@ var SearchTasklist = common.Shortcut{
var lastPageToken string
var lastHasMore bool
var notice string
- currentBody := body
+ params := buildSearchPageParams(runtime.Str("page-token"))
for page := 0; page < pageLimit; page++ {
- data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
+ data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", params, body)
if err != nil {
return err
}
@@ -87,7 +89,7 @@ var SearchTasklist = common.Shortcut{
if !lastHasMore || lastPageToken == "" {
break
}
- currentBody["page_token"] = lastPageToken
+ params["page_token"] = lastPageToken
}
tasklists := make([]map[string]interface{}, 0, len(rawItems))
@@ -170,9 +172,6 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
if len(filter) > 0 {
body["filter"] = filter
}
- if pageToken := runtime.Str("page-token"); pageToken != "" {
- body["page_token"] = pageToken
- }
return body, nil
}
diff --git a/shortcuts/task/task_tasklist_search_test.go b/shortcuts/task/task_tasklist_search_test.go
index 6ae11e4ef..82f1c3173 100644
--- a/shortcuts/task/task_tasklist_search_test.go
+++ b/shortcuts/task/task_tasklist_search_test.go
@@ -33,8 +33,8 @@ func TestBuildTasklistSearchBody(t *testing.T) {
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
createTime := filter["create_time"].(map[string]interface{})
- if body["page_token"] != "pt_tl" {
- t.Fatalf("unexpected body: %#v", body)
+ if _, present := body["page_token"]; present {
+ t.Fatalf("body unexpectedly contains page_token: %#v", body)
}
if filter["user_id"].([]string)[0] != "ou_creator" {
t.Fatalf("unexpected filter: %#v", filter)
@@ -80,9 +80,10 @@ func TestBuildTasklistSearchBody(t *testing.T) {
func TestSearchTasklist_DryRun(t *testing.T) {
tests := []struct {
- name string
- setup func(*cobra.Command)
- wantParts []string
+ name string
+ setup func(*cobra.Command)
+ wantPageToken string
+ wantParts []string
}{
{
name: "valid dry run",
@@ -90,7 +91,8 @@ func TestSearchTasklist_DryRun(t *testing.T) {
_ = cmd.Flags().Set("query", "Q2")
_ = cmd.Flags().Set("page-token", "pt_tl")
},
- wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
+ wantPageToken: "pt_tl",
+ wantParts: []string{`"query":"Q2"`},
},
{
name: "dry run error on invalid create time",
@@ -116,7 +118,11 @@ func TestSearchTasklist_DryRun(t *testing.T) {
t.Fatalf("Validate() error = %v", err)
}
}
- out := SearchTasklist.DryRun(nil, runtime).Format()
+ preview := SearchTasklist.DryRun(nil, runtime)
+ if tt.wantPageToken != "" {
+ assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
+ }
+ out := preview.Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
diff --git a/shortcuts/task/task_update.go b/shortcuts/task/task_update.go
index e12c33b10..00cf7f8c4 100644
--- a/shortcuts/task/task_update.go
+++ b/shortcuts/task/task_update.go
@@ -27,27 +27,42 @@ var UpdateTask = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
- {Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
+ {Name: "task-id", Desc: "task GUID or task applink URL (comma-separated for multiple)", Required: true},
{Name: "summary", Desc: "task title"},
{Name: "description", Desc: "task description"},
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
{Name: "data", Desc: "JSON payload for task object"},
},
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ _, err := parseTaskGUIDs(runtime.Str("task-id"))
+ return err
+ },
+
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTaskUpdateBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
- taskIds := strings.Split(runtime.Str("task-id"), ",")
- taskId := url.PathEscape(strings.TrimSpace(taskIds[0]))
- return common.NewDryRunAPI().
- PATCH("/open-apis/task/v2/tasks/" + taskId).
- Params(map[string]interface{}{"user_id_type": "open_id"}).
- Body(body)
+ taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
+ if err != nil {
+ return common.NewDryRunAPI().Set("error", err.Error())
+ }
+ preview := common.NewDryRunAPI()
+ for _, taskID := range taskIDs {
+ preview.PATCH("/open-apis/task/v2/tasks/" + url.PathEscape(taskID)).
+ Params(map[string]interface{}{"user_id_type": "open_id"}).
+ Body(body)
+ }
+ return preview
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
+ if err != nil {
+ return err
+ }
+
body, err := buildTaskUpdateBody(runtime)
if err != nil {
// buildTaskUpdateBody already returns a typed validation error;
@@ -55,17 +70,11 @@ var UpdateTask = common.Shortcut{
return err
}
- taskIds := strings.Split(runtime.Str("task-id"), ",")
var updatedTasks []map[string]interface{}
- for _, taskId := range taskIds {
- taskId = strings.TrimSpace(taskId)
- if taskId == "" {
- continue
- }
-
+ for _, taskID := range taskIDs {
params := map[string]interface{}{"user_id_type": "open_id"}
- data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId), params, body)
+ data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, body)
if err != nil {
return err
}
@@ -76,19 +85,28 @@ var UpdateTask = common.Shortcut{
}
}
+ updateFields, _ := body["update_fields"].([]string)
var tasks []map[string]interface{}
for _, task := range updatedTasks {
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
+ confirmed := make(map[string]interface{})
+ for _, field := range updateFields {
+ if value, ok := task[field]; ok {
+ confirmed[field] = value
+ }
+ }
tasks = append(tasks, map[string]interface{}{
- "guid": guid,
- "url": urlVal,
+ "guid": guid,
+ "url": urlVal,
+ "confirmed": confirmed,
})
}
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
- "tasks": tasks,
+ "updated_fields": updateFields,
+ "tasks": tasks,
}
runtime.OutFormat(outData, &output.Meta{Count: len(updatedTasks)}, func(w io.Writer) {
@@ -112,6 +130,26 @@ var UpdateTask = common.Shortcut{
},
}
+func parseTaskGUIDs(input string) ([]string, error) {
+ parts := strings.Split(input, ",")
+ taskGUIDs := make([]string, 0, len(parts))
+ for _, part := range parts {
+ if strings.TrimSpace(part) == "" {
+ continue
+ }
+ guid, err := parseTaskGUID(part)
+ if err != nil {
+ return nil, err
+ }
+ taskGUIDs = append(taskGUIDs, guid)
+ }
+ if len(taskGUIDs) == 0 {
+ _, err := parseTaskGUID("")
+ return nil, err
+ }
+ return taskGUIDs, nil
+}
+
func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
taskObj := make(map[string]interface{})
var updateFields []string
diff --git a/shortcuts/task/task_update_test.go b/shortcuts/task/task_update_test.go
new file mode 100644
index 000000000..396477db7
--- /dev/null
+++ b/shortcuts/task/task_update_test.go
@@ -0,0 +1,201 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package task
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "reflect"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/shortcuts/common"
+ "github.com/spf13/cobra"
+)
+
+func TestParseTaskGUIDs(t *testing.T) {
+ got, err := parseTaskGUIDs(" task-guid-1, https://applink.larksuite.com/client/todo/detail?guid=task-guid-2 ")
+ if err != nil {
+ t.Fatalf("parseTaskGUIDs() error = %v", err)
+ }
+ want := []string{"task-guid-1", "task-guid-2"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("parseTaskGUIDs() = %v, want %v", got, want)
+ }
+
+ _, err = parseTaskGUIDs("task-guid-1,t12345")
+ if err == nil {
+ t.Fatal("parseTaskGUIDs() error = nil, want invalid display-number error")
+ }
+}
+
+func TestTaskUpdateDryRunPreviewsEveryTaskID(t *testing.T) {
+ cmd := &cobra.Command{}
+ cmd.Flags().String("task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2", "")
+ cmd.Flags().String("summary", "updated", "")
+ cmd.Flags().String("description", "", "")
+ cmd.Flags().String("due", "", "")
+ cmd.Flags().String("data", "", "")
+
+ preview := UpdateTask.DryRun(context.Background(), &common.RuntimeContext{Cmd: cmd})
+ payload, err := json.Marshal(preview)
+ if err != nil {
+ t.Fatalf("marshal dry-run preview: %v", err)
+ }
+
+ var got struct {
+ API []struct {
+ Method string `json:"method"`
+ URL string `json:"url"`
+ Params map[string]interface{} `json:"params"`
+ Body map[string]interface{} `json:"body"`
+ } `json:"api"`
+ }
+ if err := json.Unmarshal(payload, &got); err != nil {
+ t.Fatalf("decode dry-run preview: %v", err)
+ }
+ if len(got.API) != 2 {
+ t.Fatalf("dry-run API calls = %d, want 2; payload: %s", len(got.API), payload)
+ }
+
+ wantURLs := []string{
+ "/open-apis/task/v2/tasks/task-guid-1",
+ "/open-apis/task/v2/tasks/task-guid-2",
+ }
+ for i, call := range got.API {
+ if call.Method != "PATCH" {
+ t.Errorf("api[%d].method = %q, want PATCH", i, call.Method)
+ }
+ if call.URL != wantURLs[i] {
+ t.Errorf("api[%d].url = %q, want %q", i, call.URL, wantURLs[i])
+ }
+ if !reflect.DeepEqual(call.Params, map[string]interface{}{"user_id_type": "open_id"}) {
+ t.Errorf("api[%d].params = %#v", i, call.Params)
+ }
+ if !reflect.DeepEqual(call.Body, got.API[0].Body) {
+ t.Errorf("api[%d].body = %#v, want same body as first call %#v", i, call.Body, got.API[0].Body)
+ }
+ }
+}
+
+func TestTaskUpdateNormalizesAllIDsAndReturnsConfirmedFields(t *testing.T) {
+ f, stdout, _, reg := taskShortcutTestFactory(t)
+ warmTenantToken(t, f, reg)
+
+ first := &httpmock.Stub{
+ Method: "PATCH",
+ URL: "/open-apis/task/v2/tasks/task-guid-1",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "success",
+ "data": map[string]interface{}{
+ "task": map[string]interface{}{
+ "guid": "task-guid-1",
+ "url": "https://example.com/task-guid-1",
+ "summary": "server summary one",
+ "description": "server description one",
+ },
+ },
+ },
+ }
+ second := &httpmock.Stub{
+ Method: "PATCH",
+ URL: "/open-apis/task/v2/tasks/task-guid-2",
+ Body: map[string]interface{}{
+ "code": 0, "msg": "success",
+ "data": map[string]interface{}{
+ "task": map[string]interface{}{
+ "guid": "task-guid-2",
+ "url": "https://example.com/task-guid-2",
+ "summary": "server summary two",
+ },
+ },
+ },
+ }
+ reg.Register(first)
+ reg.Register(second)
+
+ err := runMountedTaskShortcut(t, UpdateTask, []string{
+ "+update",
+ "--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2",
+ "--summary", "requested summary",
+ "--description", "requested description",
+ "--format", "json",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("UpdateTask error = %v", err)
+ }
+ reg.Verify(t)
+
+ var envelope map[string]interface{}
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode output: %v\n%s", err, stdout.String())
+ }
+ data, ok := envelope["data"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("data = %#v, want object", envelope["data"])
+ }
+ if got := stringSlice(data["updated_fields"]); !reflect.DeepEqual(got, []string{"summary", "description"}) {
+ t.Fatalf("updated_fields = %v, want [summary description]", got)
+ }
+
+ tasks, ok := data["tasks"].([]interface{})
+ if !ok || len(tasks) != 2 {
+ t.Fatalf("tasks = %#v, want two tasks", data["tasks"])
+ }
+ firstTask := tasks[0].(map[string]interface{})
+ if firstTask["guid"] != "task-guid-1" || firstTask["url"] != "https://example.com/task-guid-1" {
+ t.Fatalf("first task identifiers = %#v", firstTask)
+ }
+ if got := firstTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
+ "summary": "server summary one", "description": "server description one",
+ }) {
+ t.Fatalf("first confirmed = %#v", got)
+ }
+
+ secondTask := tasks[1].(map[string]interface{})
+ if got := secondTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
+ "summary": "server summary two",
+ }) {
+ t.Fatalf("second confirmed = %#v; omitted server fields must not be echoed from the request", got)
+ }
+}
+
+func TestTaskUpdateValidatesEveryIDBeforeFirstWrite(t *testing.T) {
+ f, stdout, _, reg := taskShortcutTestFactory(t)
+ warmTenantToken(t, f, reg)
+
+ err := runMountedTaskShortcut(t, UpdateTask, []string{
+ "+update",
+ "--task-id", "task-guid-1,t12345",
+ "--summary", "must not be written",
+ "--format", "json",
+ "--as", "bot",
+ }, f, stdout)
+ if err == nil {
+ t.Fatal("UpdateTask error = nil, want invalid task ID error")
+ }
+
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
+ t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
+ }
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
+ t.Fatalf("error param = %#v, want --task-id", validationErr)
+ }
+}
+
+func stringSlice(value interface{}) []string {
+ items, _ := value.([]interface{})
+ result := make([]string, 0, len(items))
+ for _, item := range items {
+ if str, ok := item.(string); ok {
+ result = append(result, str)
+ }
+ }
+ return result
+}
diff --git a/shortcuts/whiteboard/shortcuts.go b/shortcuts/whiteboard/shortcuts.go
index 36840dac1..3737c32c5 100644
--- a/shortcuts/whiteboard/shortcuts.go
+++ b/shortcuts/whiteboard/shortcuts.go
@@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
WhiteboardUpdate,
WhiteboardUpdateOld,
+ WhiteboardExport,
WhiteboardQuery,
}
}
diff --git a/shortcuts/whiteboard/whiteboard_export.go b/shortcuts/whiteboard/whiteboard_export.go
new file mode 100644
index 000000000..de64f37b1
--- /dev/null
+++ b/shortcuts/whiteboard/whiteboard_export.go
@@ -0,0 +1,728 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+package whiteboard
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/extension/fileio"
+ "github.com/larksuite/cli/shortcuts/common"
+ larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
+)
+
+const (
+ // WhiteboardExportAsPreview exports a whiteboard preview image.
+ WhiteboardExportAsPreview = "preview"
+ // WhiteboardExportAsSvg exports a whiteboard as SVG.
+ WhiteboardExportAsSvg = "svg"
+ // WhiteboardExportAsSource exports Mermaid or PlantUML source extracted from the whiteboard.
+ WhiteboardExportAsSource = "source"
+ // WhiteboardExportAsRaw exports the raw whiteboard node payload.
+ WhiteboardExportAsRaw = "raw"
+
+ // Legacy output type names accepted for backward compatibility.
+ WhiteboardQueryAsImage = "image"
+ // WhiteboardQueryAsSvg is deprecated; use WhiteboardExportAsSvg.
+ WhiteboardQueryAsSvg = WhiteboardExportAsSvg
+ WhiteboardQueryAsCode = "code"
+ // WhiteboardQueryAsRaw is deprecated; use WhiteboardExportAsRaw.
+ WhiteboardQueryAsRaw = WhiteboardExportAsRaw
+)
+
+// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
+type SyntaxType int
+
+const (
+ // SyntaxTypePlantUML marks PlantUML code blocks.
+ SyntaxTypePlantUML SyntaxType = 1
+ // SyntaxTypeMermaid marks Mermaid code blocks.
+ SyntaxTypeMermaid SyntaxType = 2
+)
+
+// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
+var SyntaxTypeNameMap = map[SyntaxType]string{
+ SyntaxTypePlantUML: "plantuml",
+ SyntaxTypeMermaid: "mermaid",
+}
+
+// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
+var SyntaxTypeExtensionMap = map[SyntaxType]string{
+ SyntaxTypePlantUML: ".puml",
+ SyntaxTypeMermaid: ".mmd",
+}
+
+// String returns the CLI-facing name for the syntax type.
+func (s SyntaxType) String() string {
+ return SyntaxTypeNameMap[s]
+}
+
+// ExtensionName returns the default file extension for the syntax type.
+func (s SyntaxType) ExtensionName() string {
+ return SyntaxTypeExtensionMap[s]
+}
+
+// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
+func (s SyntaxType) IsValid() bool {
+ return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
+}
+
+var wbExportScopes = []string{"board:whiteboard:node:read"}
+var wbExportAuthTypes = []string{"user", "bot"}
+var wbExportFlags = []common.Flag{
+ {Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
+ {Name: "output-type", Desc: "output whiteboard as: preview | svg | source | raw.", Required: true, Enum: []string{"preview", "svg", "source", "raw"}},
+ {Name: "output", Desc: "output path. It is required when --output-type preview. If not specified when --output-type svg/source/raw, it will output directly.", Required: false},
+ {Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
+}
+
+var wbQueryFlags = []common.Flag{
+ {Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
+ {Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true, Enum: []string{"image", "svg", "code", "raw"}},
+ {Name: "output", Desc: "output path. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
+ {Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
+}
+
+func wbExportOutputType(runtime *common.RuntimeContext) (string, string) {
+ normalized, ok := normalizeWhiteboardExportOutputType(runtime.Str("output-type"))
+ if !ok {
+ return "", "--output-type"
+ }
+ return normalized, "--output-type"
+}
+
+func wbQueryOutputType(runtime *common.RuntimeContext) (string, string) {
+ normalized, ok := normalizeLegacyWhiteboardExportOutputType(runtime.Str("output_as"))
+ if !ok {
+ return "", "--output_as"
+ }
+ return normalized, "--output_as"
+}
+
+func normalizeWhiteboardExportOutputType(outputType string) (string, bool) {
+ switch outputType {
+ case WhiteboardExportAsPreview:
+ return WhiteboardExportAsPreview, true
+ case WhiteboardExportAsSvg:
+ return WhiteboardExportAsSvg, true
+ case WhiteboardExportAsSource:
+ return WhiteboardExportAsSource, true
+ case WhiteboardExportAsRaw:
+ return WhiteboardExportAsRaw, true
+ default:
+ return "", false
+ }
+}
+
+func normalizeLegacyWhiteboardExportOutputType(outputType string) (string, bool) {
+ switch outputType {
+ case WhiteboardQueryAsImage:
+ return WhiteboardExportAsPreview, true
+ case WhiteboardQueryAsCode:
+ return WhiteboardExportAsSource, true
+ default:
+ return normalizeWhiteboardExportOutputType(outputType)
+ }
+}
+
+func wbExportOutputTypeError(param string) *errs.ValidationError {
+ if param == "--output_as" {
+ return errs.NewValidationError(
+ errs.SubtypeInvalidArgument,
+ "--output_as flag must be one of: image | svg | code | raw",
+ ).WithParam("--output_as")
+ }
+ return errs.NewValidationError(
+ errs.SubtypeInvalidArgument,
+ "--output-type flag must be one of: preview | svg | source | raw",
+ ).WithParam("--output-type")
+}
+
+func wbExportValidate(ctx context.Context, runtime *common.RuntimeContext) error {
+ return wbExportValidateWithOutputType(ctx, runtime, wbExportOutputType)
+}
+
+func wbQueryValidate(ctx context.Context, runtime *common.RuntimeContext) error {
+ return wbExportValidateWithOutputType(ctx, runtime, wbQueryOutputType)
+}
+
+func wbExportValidateWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) error {
+ // Check if token contains control characters
+ token := runtime.Str("whiteboard-token")
+ if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
+ return err
+ }
+ outputType, outputTypeParam := outputTypeFn(runtime)
+ if outputType == "" {
+ return wbExportOutputTypeError(outputTypeParam)
+ }
+
+ out := runtime.Str("output")
+ if out != "" {
+ if _, err := runtime.ResolveSavePath(out); err != nil {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
+ }
+ }
+ if out == "" && outputType == WhiteboardExportAsPreview {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output path to export whiteboard as preview").WithParam("--output")
+ }
+ return nil
+}
+
+func wbExportDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ return wbExportDryRunWithOutputType(ctx, runtime, wbExportOutputType)
+}
+
+func wbQueryDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ return wbExportDryRunWithOutputType(ctx, runtime, wbQueryOutputType)
+}
+
+func wbExportDryRunWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) *common.DryRunAPI {
+ outputType, outputTypeParam := outputTypeFn(runtime)
+ token := runtime.Str("whiteboard-token")
+ switch outputType {
+ case WhiteboardExportAsPreview:
+ return common.NewDryRunAPI().
+ GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
+ Desc("Export preview image of given whiteboard")
+ case WhiteboardExportAsSource:
+ return common.NewDryRunAPI().
+ GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
+ Desc("Extract Mermaid/Plantuml source from given whiteboard")
+ case WhiteboardExportAsRaw:
+ return common.NewDryRunAPI().
+ GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
+ Desc("Extract raw nodes structure from given whiteboard")
+ case WhiteboardExportAsSvg:
+ return common.NewDryRunAPI().
+ POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
+ Body(map[string]string{"export_type": "svg"}).
+ Desc("Export SVG of given whiteboard")
+ default:
+ if outputTypeParam == "--output_as" {
+ return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
+ }
+ return common.NewDryRunAPI().Desc("invalid --output-type flag, must be one of: preview | svg | source | raw")
+ }
+}
+
+func wbExportExecute(ctx context.Context, runtime *common.RuntimeContext) error {
+ return wbExportExecuteWithOutputType(ctx, runtime, wbExportOutputType)
+}
+
+func wbQueryExecute(ctx context.Context, runtime *common.RuntimeContext) error {
+ return wbExportExecuteWithOutputType(ctx, runtime, wbQueryOutputType)
+}
+
+func wbExportExecuteWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) error {
+ token := runtime.Str("whiteboard-token")
+ outDir := runtime.Str("output")
+ outputType, outputTypeParam := outputTypeFn(runtime)
+ switch outputType {
+ case WhiteboardExportAsPreview:
+ return exportWhiteboardPreview(ctx, runtime, token, outDir)
+ case WhiteboardExportAsSvg:
+ return exportWhiteboardSvg(runtime, token, outDir)
+ case WhiteboardExportAsSource:
+ return exportWhiteboardCode(runtime, token, outDir)
+ case WhiteboardExportAsRaw:
+ return exportWhiteboardRaw(runtime, token, outDir)
+ default:
+ return wbExportOutputTypeError(outputTypeParam)
+ }
+}
+
+const WhiteboardExportDescription = "Export an existing whiteboard as preview image, SVG, source code or raw nodes structure."
+
+// WhiteboardExport registers the `whiteboard +export` shortcut.
+var WhiteboardExport = common.Shortcut{
+ Service: "whiteboard",
+ Command: "+export",
+ Description: WhiteboardExportDescription,
+ Risk: "read",
+ Scopes: wbExportScopes,
+ AuthTypes: wbExportAuthTypes,
+ Flags: wbExportFlags,
+ HasFormat: true,
+ Validate: wbExportValidate,
+ DryRun: wbExportDryRun,
+ Execute: wbExportExecute,
+}
+
+// WhiteboardQuery registers the hidden, backward-compatible `whiteboard +query` shortcut.
+var WhiteboardQuery = common.Shortcut{
+ Service: "whiteboard",
+ Command: "+query",
+ Description: WhiteboardExportDescription,
+ Risk: "read",
+ Scopes: wbExportScopes,
+ AuthTypes: wbExportAuthTypes,
+ Flags: wbQueryFlags,
+ HasFormat: true,
+ Hidden: true,
+ Validate: wbQueryValidate,
+ DryRun: wbQueryDryRun,
+ Execute: wbQueryExecute,
+}
+
+// exportReq defines the request body for whiteboard export APIs.
+type exportReq struct {
+ ExportType string `json:"export_type"`
+}
+
+// exportResp models the whiteboard export response envelope.
+type exportResp struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ Data struct {
+ Content string `json:"content"`
+ MimeType string `json:"mime_type"`
+ } `json:"data"`
+}
+
+// exportWhiteboardSvg exports a whiteboard as SVG and writes it to stdout or a file.
+func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
+ reqBody := exportReq{ExportType: "svg"}
+ req := &larkcore.ApiReq{
+ HttpMethod: http.MethodPost,
+ ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
+ Body: reqBody,
+ }
+
+ resp, err := runtime.DoAPI(req)
+ if err != nil {
+ return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
+ }
+
+ var exportData exportResp
+ if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
+ if exportData.Code != 0 {
+ subtype := errs.SubtypeUnknown
+ if resp.StatusCode == http.StatusNotFound {
+ subtype = errs.SubtypeNotFound
+ }
+ return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
+ }
+ } else if resp.StatusCode == http.StatusOK {
+ return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
+ if resp.StatusCode >= 500 {
+ return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
+ WithCode(resp.StatusCode).
+ WithRetryable()
+ }
+ subtype := errs.SubtypeUnknown
+ if resp.StatusCode == http.StatusNotFound {
+ subtype = errs.SubtypeNotFound
+ }
+ return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
+ WithCode(resp.StatusCode)
+ }
+
+ svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
+ if err != nil {
+ return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
+ }
+
+ if outDir == "" {
+ runtime.OutFormat(map[string]interface{}{
+ "svg_content": string(svgBytes),
+ }, nil, func(w io.Writer) {
+ fmt.Fprintf(w, "%s\n", string(svgBytes))
+ })
+ return nil
+ }
+
+ finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
+ if err != nil {
+ return err
+ }
+
+ runtime.OutFormat(map[string]interface{}{
+ "svg_path": finalPath,
+ "size_bytes": size,
+ }, nil, func(w io.Writer) {
+ fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
+ fmt.Fprintf(w, "File size: %d bytes", size)
+ })
+ return nil
+}
+
+func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
+ req := &larkcore.ApiReq{
+ HttpMethod: http.MethodGet,
+ ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
+ }
+ // Execute API request. The preview endpoint streams raw image bytes (not a
+ // JSON envelope), so classify by HTTP status: 5xx is retryable network,
+ // while 4xx remains an API-side rejection.
+ resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
+ if err != nil {
+ return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
+ }
+ if resp.StatusCode >= 400 {
+ body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
+ if resp.StatusCode >= 500 {
+ return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
+ WithCode(resp.StatusCode).
+ WithRetryable()
+ }
+ subtype := errs.SubtypeUnknown
+ if resp.StatusCode == http.StatusNotFound {
+ subtype = errs.SubtypeNotFound
+ }
+ return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
+ WithCode(resp.StatusCode)
+ }
+
+ finalPath, size, err := saveWhiteboardPreviewOutput(outDir, wbToken, runtime, resp.Header, bytes.NewReader(resp.RawBody))
+ if err != nil {
+ return err
+ }
+
+ runtime.OutFormat(map[string]interface{}{
+ "preview_image_path": finalPath,
+ "size_bytes": size,
+ }, nil, func(w io.Writer) {
+ fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
+ fmt.Fprintf(w, "Image size: %d bytes", size)
+ })
+ return nil
+}
+
+type wbNodesResp struct {
+ Data struct {
+ Nodes []interface{} `json:"nodes"`
+ } `json:"data"`
+}
+
+func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
+ data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ var nodes wbNodesResp
+ rawNodes, _ := data["nodes"]
+ if rawNodes != nil {
+ var ok bool
+ nodes.Data.Nodes, ok = rawNodes.([]interface{})
+ if !ok {
+ return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
+ }
+ }
+ return &nodes, nil
+}
+
+type syntaxInfo struct {
+ code string
+ syntaxType SyntaxType
+}
+
+func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
+ wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
+ if err != nil {
+ return err
+ }
+ if wbNodes == nil || wbNodes.Data.Nodes == nil {
+ runtime.OutFormat(map[string]interface{}{
+ "msg": "whiteboard is empty",
+ }, nil, func(w io.Writer) {
+ fmt.Fprintf(w, "Whiteboard is empty\n")
+ })
+ return nil
+ }
+
+ var syntaxBlocks []syntaxInfo
+ for _, node := range wbNodes.Data.Nodes {
+ nodeMap, ok := node.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ syntax, ok := nodeMap["syntax"]
+ if !ok {
+ continue
+ }
+ syntaxMap, ok := syntax.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ code, _ := syntaxMap["code"].(string)
+ var syntaxType SyntaxType
+ switch v := syntaxMap["syntax_type"].(type) {
+ case json.Number:
+ // runtime.ClassifyAPIResponse decodes the response with UseNumber,
+ // so numeric fields arrive as json.Number rather than float64.
+ if n, err := v.Int64(); err == nil {
+ syntaxType = SyntaxType(n)
+ }
+ case float64:
+ syntaxType = SyntaxType(v)
+ case SyntaxType:
+ syntaxType = v
+ }
+ if code != "" && syntaxType.IsValid() {
+ syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
+ }
+ }
+
+ if len(syntaxBlocks) == 0 {
+ runtime.OutFormat(map[string]interface{}{
+ "msg": "no code blocks found in whiteboard",
+ }, nil, func(w io.Writer) {
+ fmt.Fprintf(w, "No code blocks found in whiteboard\n")
+ })
+ return nil
+ }
+ // 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
+ // 如果有需求,可以调整到导出到多个文件的模式
+ if len(syntaxBlocks) > 1 {
+ runtime.OutFormat(map[string]interface{}{
+ "msg": "multiple code blocks found, cannot export directly",
+ }, nil, func(w io.Writer) {
+ fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
+ })
+ return nil
+ }
+ block := syntaxBlocks[0]
+
+ if outDir == "" {
+ runtime.OutFormat(map[string]interface{}{
+ "code": block.code,
+ "syntax_type": block.syntaxType.String(),
+ }, nil, func(w io.Writer) {
+ fmt.Fprintf(w, "%s\n", block.code)
+ })
+ return nil
+ }
+
+ finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
+ if err != nil {
+ return err
+ }
+
+ runtime.OutFormat(map[string]interface{}{
+ "output_path": finalPath,
+ }, nil, func(w io.Writer) {
+ fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
+ })
+
+ return nil
+}
+
+func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
+ wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
+ if err != nil {
+ return err
+ }
+ if wbNodes == nil || wbNodes.Data.Nodes == nil {
+ runtime.OutFormat(map[string]interface{}{
+ "msg": "whiteboard is empty",
+ }, nil, func(w io.Writer) {
+ fmt.Fprintf(w, "Whiteboard is empty\n")
+ })
+ return nil
+ }
+
+ jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
+ if err != nil {
+ return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
+ }
+
+ if outDir == "" {
+ runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
+ fmt.Fprintf(w, "%s\n", string(jsonData))
+ })
+ return nil
+ }
+
+ finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
+ if err != nil {
+ return err
+ }
+
+ runtime.OutFormat(map[string]interface{}{
+ "output_path": finalPath,
+ }, nil, func(w io.Writer) {
+ fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
+ })
+
+ return nil
+}
+
+func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
+ // Step 1: Get final output path
+ info, err := runtime.FileIO().Stat(outPath)
+ var finalPath string
+ if err == nil && info.IsDir() {
+ finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
+ } else {
+ // Fix extension in path
+ currentExt := filepath.Ext(outPath)
+ if currentExt != ext {
+ if currentExt != "" {
+ outPath = outPath[:len(outPath)-len(currentExt)]
+ }
+ outPath += ext
+ }
+ finalPath = outPath
+ }
+ if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
+ return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
+ }
+
+ // Step 2: Check overwrite
+ _, err = runtime.FileIO().Stat(finalPath)
+ if err == nil {
+ if !runtime.Bool("overwrite") {
+ return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
+ }
+ } else if !os.IsNotExist(err) {
+ return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
+ }
+
+ // Step 3: Save file
+ var contentType string
+ switch ext {
+ case ".png":
+ contentType = "image/png"
+ case ".jpg", ".jpeg":
+ contentType = "image/jpeg"
+ case ".svg":
+ contentType = "image/svg+xml"
+ case ".json":
+ contentType = "application/json"
+ case ".mmd", ".puml":
+ contentType = "text/plain"
+ }
+
+ savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
+ ContentType: contentType,
+ }, data)
+ if err != nil {
+ return "", 0, wbSaveError(err)
+ }
+
+ return finalPath, savResult.Size(), nil
+}
+
+var whiteboardPreviewContentTypeExt = map[string]string{
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+}
+
+func saveWhiteboardPreviewOutput(outPath, token string, runtime *common.RuntimeContext, header http.Header, data io.Reader) (string, int64, error) {
+ contentType := header.Get("Content-Type")
+ ext, err := whiteboardPreviewExtFromContentType(contentType)
+ if err != nil {
+ return "", 0, err
+ }
+ finalPath, err := whiteboardPreviewOutputPath(outPath, ext, token, runtime)
+ if err != nil {
+ return "", 0, err
+ }
+ return saveResolvedOutputFile(finalPath, contentType, runtime, data)
+}
+
+func whiteboardPreviewExtFromContentType(contentType string) (string, error) {
+ mediaType, _, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
+ }
+ if ext, ok := whiteboardPreviewContentTypeExt[strings.ToLower(mediaType)]; ok {
+ return ext, nil
+ }
+ if strings.TrimSpace(contentType) == "" {
+ contentType = ""
+ }
+ return "", errs.NewInternalError(
+ errs.SubtypeInvalidResponse,
+ "get whiteboard preview failed: expected image/png or image/jpeg response, got Content-Type: %s",
+ contentType,
+ )
+}
+
+func whiteboardPreviewOutputPath(outPath, ext, token string, runtime *common.RuntimeContext) (string, error) {
+ info, err := runtime.FileIO().Stat(outPath)
+ if err == nil && info.IsDir() {
+ finalPath := filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
+ if _, err := runtime.ResolveSavePath(finalPath); err != nil {
+ return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
+ }
+ return finalPath, nil
+ }
+ if err != nil && !os.IsNotExist(err) {
+ return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot check output path: %s", err).WithCause(err)
+ }
+
+ currentExt := strings.ToLower(filepath.Ext(outPath))
+ if currentExt == "" || currentExt == "." {
+ finalPath := strings.TrimSuffix(outPath, ".") + ext
+ if _, err := runtime.ResolveSavePath(finalPath); err != nil {
+ return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
+ }
+ return finalPath, nil
+ }
+ if !isWhiteboardPreviewImageExt(currentExt) {
+ return "", errs.NewValidationError(
+ errs.SubtypeInvalidArgument,
+ "invalid preview output extension %q; use .png, .jpg, .jpeg, a directory, or a path without extension",
+ currentExt,
+ ).WithParam("--output")
+ }
+ if !whiteboardPreviewExtMatches(currentExt, ext) {
+ return "", errs.NewValidationError(
+ errs.SubtypeFailedPrecondition,
+ "preview response is %s but output path has extension %s; use a matching extension or omit the extension",
+ ext,
+ currentExt,
+ ).WithParam("--output")
+ }
+ if _, err := runtime.ResolveSavePath(outPath); err != nil {
+ return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
+ }
+ return outPath, nil
+}
+
+func isWhiteboardPreviewImageExt(ext string) bool {
+ return ext == ".png" || ext == ".jpg" || ext == ".jpeg"
+}
+
+func whiteboardPreviewExtMatches(outputExt, responseExt string) bool {
+ if responseExt == ".jpg" {
+ return outputExt == ".jpg" || outputExt == ".jpeg"
+ }
+ return outputExt == responseExt
+}
+
+func saveResolvedOutputFile(finalPath, contentType string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
+ _, err := runtime.FileIO().Stat(finalPath)
+ if err == nil {
+ if !runtime.Bool("overwrite") {
+ return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
+ }
+ } else if !os.IsNotExist(err) {
+ return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
+ }
+
+ savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
+ ContentType: contentType,
+ }, data)
+ if err != nil {
+ return "", 0, wbSaveError(err)
+ }
+ return finalPath, savResult.Size(), nil
+}
diff --git a/shortcuts/whiteboard/whiteboard_query_test.go b/shortcuts/whiteboard/whiteboard_export_test.go
similarity index 82%
rename from shortcuts/whiteboard/whiteboard_query_test.go
rename to shortcuts/whiteboard/whiteboard_export_test.go
index 9ccc18782..22e1e933a 100644
--- a/shortcuts/whiteboard/whiteboard_query_test.go
+++ b/shortcuts/whiteboard/whiteboard_export_test.go
@@ -9,6 +9,7 @@ import (
"encoding/base64"
"encoding/json"
"errors"
+ "net/http"
"os"
"path/filepath"
"strings"
@@ -211,6 +212,73 @@ func TestWhiteboardQuery_Validate_TypedErrors(t *testing.T) {
}
}
+// TestWhiteboardExport_Validate verifies the canonical +export flag spelling
+// and output type names while legacy +query validation remains covered above.
+func TestWhiteboardExport_Validate(t *testing.T) {
+ ctx := context.Background()
+ chdirTemp(t)
+
+ tests := []struct {
+ name string
+ flags map[string]string
+ wantErr bool
+ wantParam string
+ }{
+ {
+ name: "valid: preview with output",
+ flags: map[string]string{
+ "whiteboard-token": "test-token-123",
+ "output-type": "preview",
+ "output": "output",
+ },
+ },
+ {
+ name: "valid: source without output",
+ flags: map[string]string{
+ "whiteboard-token": "test-token-123",
+ "output-type": "source",
+ },
+ },
+ {
+ name: "invalid: preview without output",
+ flags: map[string]string{
+ "whiteboard-token": "test-token-123",
+ "output-type": "preview",
+ },
+ wantErr: true,
+ wantParam: "--output",
+ },
+ {
+ name: "invalid: bad output-type value",
+ flags: map[string]string{
+ "whiteboard-token": "test-token-123",
+ "output-type": "image",
+ },
+ wantErr: true,
+ wantParam: "--output-type",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := WhiteboardExport.Validate(ctx, newTestRuntime(tt.flags, nil))
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("WhiteboardExport.Validate() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if err == nil {
+ return
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("error is not *errs.ValidationError: %T", err)
+ }
+ if ve.Param != tt.wantParam {
+ t.Fatalf("Param = %q, want %q", ve.Param, tt.wantParam)
+ }
+ })
+ }
+}
+
// TestExportWhiteboardPreview_HTTPError locks the download-path failure
// behavior: a failed preview download surfaces as a typed errs.* envelope, not
// a flat legacy error.
@@ -284,7 +352,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
"output": "output.png",
},
wantMethod: "GET",
- wantPath: "/open-apis/board/v1/whiteboards/test-token-123/download_as_image",
+ wantPath: "/open-apis/board/v1/whiteboards/test...-123/download_as_image",
},
{
name: "dry run code",
@@ -293,7 +361,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
"output_as": "code",
},
wantMethod: "GET",
- wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
+ wantPath: "/open-apis/board/v1/whiteboards/test...-123/nodes",
},
{
name: "dry run raw",
@@ -302,7 +370,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
"output_as": "raw",
},
wantMethod: "GET",
- wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
+ wantPath: "/open-apis/board/v1/whiteboards/test...-123/nodes",
},
}
@@ -313,6 +381,29 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
if dryRun == nil {
t.Fatalf("WhiteboardQuery.DryRun() returned nil")
}
+ var got struct {
+ API []struct {
+ Method string `json:"method"`
+ URL string `json:"url"`
+ Body map[string]interface{} `json:"body"`
+ } `json:"api"`
+ }
+ data, err := json.Marshal(dryRun)
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("Unmarshal() error = %v; data=%s", err, string(data))
+ }
+ if len(got.API) != 1 {
+ t.Fatalf("api len = %d, want 1; data=%s", len(got.API), string(data))
+ }
+ if got.API[0].Method != tt.wantMethod {
+ t.Fatalf("method = %q, want %q; data=%s", got.API[0].Method, tt.wantMethod, string(data))
+ }
+ if got.API[0].URL != tt.wantPath {
+ t.Fatalf("url = %q, want %q; data=%s", got.API[0].URL, tt.wantPath, string(data))
+ }
})
}
}
@@ -391,6 +482,32 @@ func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
if len(WhiteboardQuery.Flags) == 0 {
t.Errorf("WhiteboardQuery.Flags is empty, expected at least one flag")
}
+ if !WhiteboardQuery.Hidden {
+ t.Errorf("WhiteboardQuery should be hidden because +export is the canonical command")
+ }
+
+ // Verify WhiteboardExport is the visible canonical shortcut.
+ if WhiteboardExport.Command != "+export" {
+ t.Errorf("WhiteboardExport.Command = %q, want \"+export\"", WhiteboardExport.Command)
+ }
+ if WhiteboardExport.Service != "whiteboard" {
+ t.Errorf("WhiteboardExport.Service = %q, want \"whiteboard\"", WhiteboardExport.Service)
+ }
+ if WhiteboardExport.Hidden {
+ t.Errorf("WhiteboardExport should be visible")
+ }
+ if flag := shortcutFlag(WhiteboardExport, "output_as"); flag != nil {
+ t.Errorf("WhiteboardExport --output_as should not be registered; got %#v", *flag)
+ }
+ if flag := shortcutFlag(WhiteboardExport, "output-type"); flag == nil || flag.Hidden {
+ t.Errorf("WhiteboardExport --output-type should exist and be visible")
+ }
+ if flag := shortcutFlag(WhiteboardQuery, "output_as"); flag == nil || flag.Hidden {
+ t.Errorf("WhiteboardQuery --output_as should exist and remain visible on the hidden legacy command")
+ }
+ if flag := shortcutFlag(WhiteboardQuery, "output-type"); flag != nil {
+ t.Errorf("WhiteboardQuery --output-type should not be registered; got %#v", *flag)
+ }
}
// TestSaveOutputFile verifies output saving, overwrite handling, and extension-specific paths.
@@ -862,10 +979,11 @@ func TestExportWhiteboardPreview(t *testing.T) {
// Mock download preview image API response with RawBody
reg.Register(&httpmock.Stub{
- Method: "GET",
- URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
- Status: 200,
- RawBody: []byte("fake PNG image data"),
+ Method: "GET",
+ URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
+ Status: 200,
+ RawBody: []byte("fake PNG image data"),
+ ContentType: "image/png",
})
args := []string{"+query", "--whiteboard-token", "test-token-preview", "--output_as", "image", "--output", "output", "--overwrite"}
@@ -883,6 +1001,158 @@ func TestExportWhiteboardPreview(t *testing.T) {
}
}
+// TestExportWhiteboardPreview_UsesContentTypeExtension verifies preview image
+// downloads are saved according to the API response Content-Type rather than a
+// hard-coded PNG suffix.
+func TestExportWhiteboardPreview_UsesContentTypeExtension(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ chdirTemp(t)
+
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/board/v1/whiteboards/test-token-preview-jpeg/download_as_image",
+ Status: 200,
+ RawBody: []byte("fake JPEG image data"),
+ ContentType: "image/jpeg",
+ })
+
+ args := []string{"+export", "--whiteboard-token", "test-token-preview-jpeg", "--output-type", "preview", "--output", "output", "--overwrite"}
+ if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
+ t.Fatalf("err=%v", err)
+ }
+
+ if _, err := os.Stat("output.png"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("output.png should not exist when response Content-Type is image/jpeg, stat err=%v", err)
+ }
+ data, err := os.ReadFile("output.jpg")
+ if err != nil {
+ t.Fatalf("ReadFile() error: %v", err)
+ }
+ if string(data) != "fake JPEG image data" {
+ t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
+ }
+}
+
+func TestExportWhiteboardPreview_RejectsNonImageContentTypeWithoutSiblingOverwrite(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ chdirTemp(t)
+
+ if err := os.WriteFile("report.html", []byte("keep me"), 0644); err != nil {
+ t.Fatalf("WriteFile() error: %v", err)
+ }
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/board/v1/whiteboards/test-token-preview-html/download_as_image",
+ Status: 200,
+ RawBody: []byte("bad gateway"),
+ ContentType: "text/html; charset=utf-8",
+ })
+
+ args := []string{"+export", "--whiteboard-token", "test-token-preview-html", "--output-type", "preview", "--output", "report.png", "--overwrite"}
+ err := runShortcut(t, WhiteboardExport, args, factory, stdout)
+ if err == nil {
+ t.Fatal("expected error for non-image preview response")
+ }
+ assertInvalidResponse(t, err)
+
+ data, readErr := os.ReadFile("report.html")
+ if readErr != nil {
+ t.Fatalf("ReadFile() error: %v", readErr)
+ }
+ if string(data) != "keep me" {
+ t.Fatalf("report.html was overwritten: %q", string(data))
+ }
+ if _, statErr := os.Stat("report.png"); !errors.Is(statErr, os.ErrNotExist) {
+ t.Fatalf("report.png should not be written on invalid response, stat err=%v", statErr)
+ }
+}
+
+func TestExportWhiteboardPreview_IgnoresContentDispositionExtension(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ chdirTemp(t)
+
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/board/v1/whiteboards/test-token-preview-disposition/download_as_image",
+ Status: 200,
+ RawBody: []byte("fake JPEG image data"),
+ Headers: http.Header{
+ "Content-Type": []string{"image/jpeg"},
+ "Content-Disposition": []string{`attachment; filename="payload.sh"`},
+ },
+ })
+
+ args := []string{"+export", "--whiteboard-token", "test-token-preview-disposition", "--output-type", "preview", "--output", "output", "--overwrite"}
+ if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
+ t.Fatalf("err=%v", err)
+ }
+
+ if _, err := os.Stat("output.sh"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("output.sh should not be created from Content-Disposition, stat err=%v", err)
+ }
+ data, err := os.ReadFile("output.jpg")
+ if err != nil {
+ t.Fatalf("ReadFile() error: %v", err)
+ }
+ if string(data) != "fake JPEG image data" {
+ t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
+ }
+}
+
+func TestExportWhiteboardPreview_RejectsMismatchedExplicitExtension(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ chdirTemp(t)
+
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/board/v1/whiteboards/test-token-preview-mismatch/download_as_image",
+ Status: 200,
+ RawBody: []byte("fake JPEG image data"),
+ ContentType: "image/jpeg",
+ })
+
+ args := []string{"+export", "--whiteboard-token", "test-token-preview-mismatch", "--output-type", "preview", "--output", "report.png", "--overwrite"}
+ err := runShortcut(t, WhiteboardExport, args, factory, stdout)
+ if err == nil {
+ t.Fatal("expected error for mismatched explicit extension")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
+ }
+ if ve.Subtype != errs.SubtypeFailedPrecondition || ve.Param != "--output" {
+ t.Fatalf("validation details = subtype %q param %q, want %q --output", ve.Subtype, ve.Param, errs.SubtypeFailedPrecondition)
+ }
+ if _, statErr := os.Stat("report.jpg"); !errors.Is(statErr, os.ErrNotExist) {
+ t.Fatalf("report.jpg should not be created when explicit path mismatches, stat err=%v", statErr)
+ }
+}
+
+func TestExportWhiteboardPreview_AllowsMatchingExplicitExtension(t *testing.T) {
+ factory, stdout, reg := newExecuteFactory(t)
+ chdirTemp(t)
+
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/board/v1/whiteboards/test-token-preview-matching/download_as_image",
+ Status: 200,
+ RawBody: []byte("fake JPEG image data"),
+ ContentType: "image/jpeg",
+ })
+
+ args := []string{"+export", "--whiteboard-token", "test-token-preview-matching", "--output-type", "preview", "--output", "report.jpeg", "--overwrite"}
+ if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
+ t.Fatalf("err=%v", err)
+ }
+ data, err := os.ReadFile("report.jpeg")
+ if err != nil {
+ t.Fatalf("ReadFile() error: %v", err)
+ }
+ if string(data) != "fake JPEG image data" {
+ t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
+ }
+}
+
// TestExportWhiteboardRaw_EmptyNodes verifies raw export reports empty whiteboards.
func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -1522,3 +1792,12 @@ func chdirTemp(t *testing.T) {
}
t.Cleanup(func() { os.Chdir(orig) })
}
+
+func shortcutFlag(shortcut common.Shortcut, name string) *common.Flag {
+ for i := range shortcut.Flags {
+ if shortcut.Flags[i].Name == name {
+ return &shortcut.Flags[i]
+ }
+ }
+ return nil
+}
diff --git a/shortcuts/whiteboard/whiteboard_query.go b/shortcuts/whiteboard/whiteboard_query.go
deleted file mode 100644
index e650ecb45..000000000
--- a/shortcuts/whiteboard/whiteboard_query.go
+++ /dev/null
@@ -1,494 +0,0 @@
-// Copyright (c) 2026 Lark Technologies Pte. Ltd.
-// SPDX-License-Identifier: MIT
-package whiteboard
-
-import (
- "bytes"
- "context"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/larksuite/cli/errs"
- "github.com/larksuite/cli/extension/fileio"
- "github.com/larksuite/cli/shortcuts/common"
- larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
-)
-
-const (
- // WhiteboardQueryAsImage exports a whiteboard preview image.
- WhiteboardQueryAsImage = "image"
- // WhiteboardQueryAsSvg exports a whiteboard as SVG.
- WhiteboardQueryAsSvg = "svg"
- // WhiteboardQueryAsCode exports Mermaid or PlantUML source extracted from the whiteboard.
- WhiteboardQueryAsCode = "code"
- // WhiteboardQueryAsRaw exports the raw whiteboard node payload.
- WhiteboardQueryAsRaw = "raw"
-)
-
-// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
-type SyntaxType int
-
-const (
- // SyntaxTypePlantUML marks PlantUML code blocks.
- SyntaxTypePlantUML SyntaxType = 1
- // SyntaxTypeMermaid marks Mermaid code blocks.
- SyntaxTypeMermaid SyntaxType = 2
-)
-
-// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
-var SyntaxTypeNameMap = map[SyntaxType]string{
- SyntaxTypePlantUML: "plantuml",
- SyntaxTypeMermaid: "mermaid",
-}
-
-// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
-var SyntaxTypeExtensionMap = map[SyntaxType]string{
- SyntaxTypePlantUML: ".puml",
- SyntaxTypeMermaid: ".mmd",
-}
-
-// String returns the CLI-facing name for the syntax type.
-func (s SyntaxType) String() string {
- return SyntaxTypeNameMap[s]
-}
-
-// ExtensionName returns the default file extension for the syntax type.
-func (s SyntaxType) ExtensionName() string {
- return SyntaxTypeExtensionMap[s]
-}
-
-// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
-func (s SyntaxType) IsValid() bool {
- return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
-}
-
-// WhiteboardQuery registers the `whiteboard +query` shortcut.
-var WhiteboardQuery = common.Shortcut{
- Service: "whiteboard",
- Command: "+query",
- Description: "Query a existing whiteboard, export it as preview image or raw nodes structure.",
- Risk: "read",
- Scopes: []string{"board:whiteboard:node:read"},
- AuthTypes: []string{"user", "bot"},
- Flags: []common.Flag{
- {Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
- {Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true},
- {Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
- {Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
- },
- HasFormat: true,
- Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
- // Check if token contains control characters
- token := runtime.Str("whiteboard-token")
- if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
- return err
- }
- out := runtime.Str("output")
- if out != "" {
- if _, err := runtime.ResolveSavePath(out); err != nil {
- return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
- }
- }
- if out == "" && runtime.Str("output_as") == WhiteboardQueryAsImage {
- return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output directory to query whiteboard as image").WithParam("--output")
- }
-
- as := runtime.Str("output_as")
- if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsSvg && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
- return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
- }
- return nil
- },
- DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
- as := runtime.Str("output_as")
- token := runtime.Str("whiteboard-token")
- switch as {
- case WhiteboardQueryAsImage:
- return common.NewDryRunAPI().
- GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
- Desc("Export preview image of given whiteboard")
- case WhiteboardQueryAsCode:
- return common.NewDryRunAPI().
- GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
- Desc("Extract Mermaid/Plantuml code from given whiteboard")
- case WhiteboardQueryAsRaw:
- return common.NewDryRunAPI().
- GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
- Desc("Extract raw nodes structure from given whiteboard")
- case WhiteboardQueryAsSvg:
- return common.NewDryRunAPI().
- POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
- Body(map[string]string{"export_type": "svg"}).
- Desc("Export SVG of given whiteboard")
- default:
- return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
- }
- },
- Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
- // 构建 API 请求
- token := runtime.Str("whiteboard-token")
- outDir := runtime.Str("output")
- as := runtime.Str("output_as")
- switch as {
- case WhiteboardQueryAsImage:
- return exportWhiteboardPreview(ctx, runtime, token, outDir)
- case WhiteboardQueryAsSvg:
- return exportWhiteboardSvg(runtime, token, outDir)
- case WhiteboardQueryAsCode:
- return exportWhiteboardCode(runtime, token, outDir)
- case WhiteboardQueryAsRaw:
- return exportWhiteboardRaw(runtime, token, outDir)
- default:
- return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
- }
-
- },
-}
-
-// exportReq defines the request body for whiteboard export APIs.
-type exportReq struct {
- ExportType string `json:"export_type"`
-}
-
-// exportResp models the whiteboard export response envelope.
-type exportResp struct {
- Code int `json:"code"`
- Msg string `json:"msg"`
- Data struct {
- Content string `json:"content"`
- MimeType string `json:"mime_type"`
- } `json:"data"`
-}
-
-// exportWhiteboardSvg exports a whiteboard as SVG and writes it to stdout or a file.
-func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
- reqBody := exportReq{ExportType: "svg"}
- req := &larkcore.ApiReq{
- HttpMethod: http.MethodPost,
- ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
- Body: reqBody,
- }
-
- resp, err := runtime.DoAPI(req)
- if err != nil {
- return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
- }
-
- var exportData exportResp
- if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
- if exportData.Code != 0 {
- subtype := errs.SubtypeUnknown
- if resp.StatusCode == http.StatusNotFound {
- subtype = errs.SubtypeNotFound
- }
- return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
- }
- } else if resp.StatusCode == http.StatusOK {
- return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
- }
-
- if resp.StatusCode != http.StatusOK {
- body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
- if resp.StatusCode >= 500 {
- return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
- WithCode(resp.StatusCode).
- WithRetryable()
- }
- subtype := errs.SubtypeUnknown
- if resp.StatusCode == http.StatusNotFound {
- subtype = errs.SubtypeNotFound
- }
- return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
- WithCode(resp.StatusCode)
- }
-
- svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
- if err != nil {
- return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
- }
-
- if outDir == "" {
- runtime.OutFormat(map[string]interface{}{
- "svg_content": string(svgBytes),
- }, nil, func(w io.Writer) {
- fmt.Fprintf(w, "%s\n", string(svgBytes))
- })
- return nil
- }
-
- finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
- if err != nil {
- return err
- }
-
- runtime.OutFormat(map[string]interface{}{
- "svg_path": finalPath,
- "size_bytes": size,
- }, nil, func(w io.Writer) {
- fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
- fmt.Fprintf(w, "File size: %d bytes", size)
- })
- return nil
-}
-
-func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
- req := &larkcore.ApiReq{
- HttpMethod: http.MethodGet,
- ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
- }
- // Execute API request. The preview endpoint streams raw image bytes (not a
- // JSON envelope), so classify by HTTP status: 5xx is retryable network,
- // while 4xx remains an API-side rejection.
- resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
- if err != nil {
- return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
- }
- if resp.StatusCode >= 400 {
- body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
- if resp.StatusCode >= 500 {
- return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
- WithCode(resp.StatusCode).
- WithRetryable()
- }
- subtype := errs.SubtypeUnknown
- if resp.StatusCode == http.StatusNotFound {
- subtype = errs.SubtypeNotFound
- }
- return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
- WithCode(resp.StatusCode)
- }
-
- finalPath, size, err := saveOutputFile(outDir, ".png", wbToken, runtime, bytes.NewReader(resp.RawBody))
- if err != nil {
- return err
- }
-
- runtime.OutFormat(map[string]interface{}{
- "preview_image_path": finalPath,
- "size_bytes": size,
- }, nil, func(w io.Writer) {
- fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
- fmt.Fprintf(w, "Image size: %d bytes", size)
- })
- return nil
-}
-
-type wbNodesResp struct {
- Data struct {
- Nodes []interface{} `json:"nodes"`
- } `json:"data"`
-}
-
-func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
- data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
- if err != nil {
- return nil, err
- }
- var nodes wbNodesResp
- rawNodes, _ := data["nodes"]
- if rawNodes != nil {
- var ok bool
- nodes.Data.Nodes, ok = rawNodes.([]interface{})
- if !ok {
- return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
- }
- }
- return &nodes, nil
-}
-
-type syntaxInfo struct {
- code string
- syntaxType SyntaxType
-}
-
-func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
- wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
- if err != nil {
- return err
- }
- if wbNodes == nil || wbNodes.Data.Nodes == nil {
- runtime.OutFormat(map[string]interface{}{
- "msg": "whiteboard is empty",
- }, nil, func(w io.Writer) {
- fmt.Fprintf(w, "Whiteboard is empty\n")
- })
- return nil
- }
-
- var syntaxBlocks []syntaxInfo
- for _, node := range wbNodes.Data.Nodes {
- nodeMap, ok := node.(map[string]interface{})
- if !ok {
- continue
- }
- syntax, ok := nodeMap["syntax"]
- if !ok {
- continue
- }
- syntaxMap, ok := syntax.(map[string]interface{})
- if !ok {
- continue
- }
- code, _ := syntaxMap["code"].(string)
- var syntaxType SyntaxType
- switch v := syntaxMap["syntax_type"].(type) {
- case json.Number:
- // runtime.ClassifyAPIResponse decodes the response with UseNumber,
- // so numeric fields arrive as json.Number rather than float64.
- if n, err := v.Int64(); err == nil {
- syntaxType = SyntaxType(n)
- }
- case float64:
- syntaxType = SyntaxType(v)
- case SyntaxType:
- syntaxType = v
- }
- if code != "" && syntaxType.IsValid() {
- syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
- }
- }
-
- if len(syntaxBlocks) == 0 {
- runtime.OutFormat(map[string]interface{}{
- "msg": "no code blocks found in whiteboard",
- }, nil, func(w io.Writer) {
- fmt.Fprintf(w, "No code blocks found in whiteboard\n")
- })
- return nil
- }
- // 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
- // 如果有需求,可以调整到导出到多个文件的模式
- if len(syntaxBlocks) > 1 {
- runtime.OutFormat(map[string]interface{}{
- "msg": "multiple code blocks found, cannot export directly",
- }, nil, func(w io.Writer) {
- fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
- })
- return nil
- }
- block := syntaxBlocks[0]
-
- if outDir == "" {
- runtime.OutFormat(map[string]interface{}{
- "code": block.code,
- "syntax_type": block.syntaxType.String(),
- }, nil, func(w io.Writer) {
- fmt.Fprintf(w, "%s\n", block.code)
- })
- return nil
- }
-
- finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
- if err != nil {
- return err
- }
-
- runtime.OutFormat(map[string]interface{}{
- "output_path": finalPath,
- }, nil, func(w io.Writer) {
- fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
- })
-
- return nil
-}
-
-func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
- wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
- if err != nil {
- return err
- }
- if wbNodes == nil || wbNodes.Data.Nodes == nil {
- runtime.OutFormat(map[string]interface{}{
- "msg": "whiteboard is empty",
- }, nil, func(w io.Writer) {
- fmt.Fprintf(w, "Whiteboard is empty\n")
- })
- return nil
- }
-
- jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
- if err != nil {
- return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
- }
-
- if outDir == "" {
- runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
- fmt.Fprintf(w, "%s\n", string(jsonData))
- })
- return nil
- }
-
- finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
- if err != nil {
- return err
- }
-
- runtime.OutFormat(map[string]interface{}{
- "output_path": finalPath,
- }, nil, func(w io.Writer) {
- fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
- })
-
- return nil
-}
-
-func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
- // Step 1: Get final output path
- info, err := runtime.FileIO().Stat(outPath)
- var finalPath string
- if err == nil && info.IsDir() {
- finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
- } else {
- // Fix extension in path
- currentExt := filepath.Ext(outPath)
- if currentExt != ext {
- if currentExt != "" {
- outPath = outPath[:len(outPath)-len(currentExt)]
- }
- outPath += ext
- }
- finalPath = outPath
- }
- if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
- return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
- }
-
- // Step 2: Check overwrite
- _, err = runtime.FileIO().Stat(finalPath)
- if err == nil {
- if !runtime.Bool("overwrite") {
- return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
- }
- } else if !os.IsNotExist(err) {
- return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
- }
-
- // Step 3: Save file
- var contentType string
- switch ext {
- case ".png":
- contentType = "image/png"
- case ".svg":
- contentType = "image/svg+xml"
- case ".json":
- contentType = "application/json"
- case ".mmd", ".puml":
- contentType = "text/plain"
- }
-
- savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
- ContentType: contentType,
- }, data)
- if err != nil {
- return "", 0, wbSaveError(err)
- }
-
- return finalPath, savResult.Size(), nil
-}
diff --git a/shortcuts/whiteboard/whiteboard_update_test.go b/shortcuts/whiteboard/whiteboard_update_test.go
index e48ef8e12..9d3a5af9e 100644
--- a/shortcuts/whiteboard/whiteboard_update_test.go
+++ b/shortcuts/whiteboard/whiteboard_update_test.go
@@ -255,6 +255,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
got := Shortcuts()
want := []string{
"+update",
+ "+export",
"+query",
}
diff --git a/shortcuts/wiki/wiki_node_create.go b/shortcuts/wiki/wiki_node_create.go
index 6356ede84..a03d7e201 100644
--- a/shortcuts/wiki/wiki_node_create.go
+++ b/shortcuts/wiki/wiki_node_create.go
@@ -73,7 +73,7 @@ var WikiNodeCreate = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dry := buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime))
if runtime.IsBot() {
- dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new wiki node.")
+ dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new wiki node.")
}
return dry
},
diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go
index 8d183bcdc..face88615 100644
--- a/shortcuts/wiki/wiki_node_create_test.go
+++ b/shortcuts/wiki/wiki_node_create_test.go
@@ -635,7 +635,7 @@ func TestWikiNodeCreateBotAutoGrantSuccess(t *testing.T) {
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
- if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new wiki node." {
+ if grant["message"] != "Granted the current CLI user full_access on the new wiki node." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md
index 1476b44bf..5be1a8793 100644
--- a/skills/lark-apps/SKILL.md
+++ b/skills/lark-apps/SKILL.md
@@ -1,7 +1,7 @@
---
name: lark-apps
version: 1.0.0
-description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理、应用角色与成员管理、自动化触发器(定时/记录变更/Webhook/飞书审批)。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、应用角色/角色成员、线上日志、接口请求量、错误量、延迟、访问量、环境变量、给妙搭应用配自动化任务/定时触发/审批通过后自动触发时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
+description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、本地全栈开发、云端生成迭代、创意设计(UI mockup / 可交互原型 / 线框图 / 落地页 / 仪表盘 / 幻灯片 deck / 视觉探索)、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理、应用角色与成员管理、自动化触发器(定时/记录变更/Webhook/飞书审批)。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或要设计 / design / mockup / prototype / wireframe / 做 PPT / deck / 视觉探索,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、应用角色/角色成员、线上日志、接口请求量、错误量、延迟、访问量、环境变量、给妙搭应用配自动化任务/定时触发/审批通过后自动触发时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
metadata:
requires:
bins: ["lark-cli"]
@@ -10,7 +10,7 @@ metadata:
# apps (v1)
-妙搭应用属于用户资产。默认用 `--as user`;认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有三条开发路径:**本地全栈**(拉源码本地写)/ **HTML 托管**(发布静态产物)/ **云端会话**(妙搭 AI 生成)。
+妙搭应用属于用户资产。默认用 `--as user`;认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有两条开发路径:**本地开发**(拉源码本地写)/ **云端会话**(妙搭 AI 生成)。
## 身份与授权
@@ -32,16 +32,18 @@ lark-cli auth login --domain apps
| 找已有 app_id、按名字过滤应用 | `+list --keyword ` | [`lark-apps-list.md`](references/lark-apps-list.md) |
| 查单个应用详情(类型、名称、发布状态等) | `+get --app-id ` | [`lark-apps-get.md`](references/lark-apps-get.md) |
| 改应用名或描述 | `+update` | [`lark-apps-update.md`](references/lark-apps-update.md) |
-| 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) |
-| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git)。**执行前必读** [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md);修改源码还须遵守下方「平台资源与应用源码边界」 | [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) |
+| HTML 应用 / 创意模式 — 写 HTML 页面/网站、静态页、PPT/deck、落地页、仪表盘、UI mockup、原型、线框图、视觉探索 | 加载 [`creative-design/creative-design.md`](creative-design/creative-design.md)(含完整开发与发布流程) | [`creative-design/creative-design.md`](creative-design/creative-design.md) |
+| 旧版存量 HTML 应用(无 Git 管理)继续上传已有静态产物 | `+html-publish`(仅兼容旧链路;新建 html / 创意模式 / creative-design 产物不得使用) | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) |
+| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git)。**执行前必读** [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md),含端到端流程和领域规则 | [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) |
| 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) |
| 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) |
| 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-list`, `+analytics-list` | [`lark-apps-observability.md`](references/lark-apps-observability.md) |
| 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / dev→online 发布 / 时间点恢复 / 查 DB 用量 | `+db-table-list`、`+db-table-get`、`+db-env-create`、`+db-data-export`/`+db-data-import`、`+db-changelog-list`、`+db-audit-status`/`+db-audit-enable`/`+db-audit-disable`/`+db-audit-list`、`+db-env-diff`/`+db-env-migrate`、`+db-recovery-diff`/`+db-recovery-apply`、`+db-quota-get` | [`lark-apps-db.md`](references/lark-apps-db.md) |
| 逐条执行 SQL(SELECT / DML / DDL);建表 / 改表 / 写 SQL 的平台规范 | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md)(含「平台 SQL 规范」:审计列 / RLS / `user_profile` / 禁用 SQL / PG 陷阱) |
| 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) |
-| **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) |
+| **部署/上线应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | 本地开发链路先按 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) 确认本次改动已 git commit + git push,再用 `+release-create` / `+release-get`;查历史用 `+release-list` | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) |
| 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference |
+| 创意模式(html)应用的评论相关操作 | 创意模式应用评论走 lark-drive 文档评论体系,读取 [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) 了解评论能力 | [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) |
| 管理 `app_...` 应用内角色、角色成员,或查询用户匹配角色 | `+role-list/get/create/update/delete`, `+role-member-list/add/remove`, `+role-match-list` | [`lark-apps-role.md`](references/lark-apps-role.md) |
| 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
| 管理妙搭应用开放 API Key(创建/查看/启停/重置/删除凭证;密钥仅 create/reset 一次性返回) | `+openapi-key-list/get/create/update/enable/disable/delete/reset` | [`lark-apps-openapi-key.md`](references/lark-apps-openapi-key.md) |
@@ -63,9 +65,9 @@ lark-cli auth login --domain apps
| 信号 | 判定 |
|---|---|
-| 静态展示 / 单页 / PPT/demo / 无后端状态 | `app_type=html`,跳过本地/云端轴,开发完按 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)(含"未提部署→先问是否发布") |
+| 静态展示 / 单页 / PPT/deck / demo / 落地页 / 仪表盘 / UI mockup / 可交互原型 / 线框图 / 视觉探索 / 无后端状态 | `app_type=html`,加载 [`creative-design/creative-design.md`](creative-design/creative-design.md)(含完整开发与发布流程) |
| 登录 / 数据库 / 持久化 / 多人协作 / 增删改查 / 报名 / 投票 / 站会 / OKR / 泛称"系统·工具" | `app_type=full_stack` |
-| 用户要自己写 / 本地 IDE·code agent / 拉源码到本地 / 交研发 | 本地全栈,读 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) |
+| 用户要自己写 / 本地 IDE·code agent / 拉源码到本地 / 交研发 | 本地开发,读 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) |
| 让妙搭 AI 云端生成 / 对话式 / 自己不碰代码 | 云端会话,读 [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
| 未表达"谁来写"偏好 | **必须先问**(本地代码开发 vs 云端 AI 生成);选定前不擅自选边、不暗示默认,不得以"需求不模糊"为由跳过提问直接 `+init` / `git clone` / `+session-create` / 首轮 `+chat` |
| 修改已有 + 当前目录是 `.spark/meta.json` 项目 | 直接继续本地按意图路由,不必问也不必判云端 |
@@ -75,16 +77,19 @@ lark-cli auth login --domain apps
- **发布意图判定**:用户要"可访问 / 线上 / 分享 / 新链接 / 上线" = 发布意图,先走发布链路、确认完成再给链接。
- 完成 ≠ 发布:云端会话完成 / `+list is_published=true` 都不代表最新内容已部署。
-- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`:进应用编辑/开发态、管理与继续开发应用的入口。发布成功后,连同发布态链接一并提供给用户(说明"管理 / 继续开发去这里");但它仅进编辑态,**不能**顶替发布态链接当分享链接。
-- 发布态链接来源:html → `+html-publish` 的 `data.url`;全栈 → `+release-get` 轮询 `finished` 给 `online_url` / `failed` 给 `error_logs`。
-- **可见范围**:发布态链接(html 的 `data.url`、全栈的 `online_url`)默认仅**创建者可见**,发给他人对方会无权限打不开。当可分享链接交付给用户前,先告知当前仅本人可见,再询问是否用 `+access-scope-set`(`tenant`/`public`/`specific`)放开(可先 `+access-scope-get` 查当前范围)。
+- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`(仅 full_stack 应用):进应用编辑/开发态、管理与继续开发应用的入口。创意模式(html)应用开发态和发布态是同一个链接,无需额外提供开发态链接。
+- 发布态链接来源:`+release-get` 轮询 `finished` 给 `online_url` / `failed` 给 `error_logs`(html 和 full_stack 统一走 `+release-get`)。
+- html 应用的主链路是创意模式开发方式:按 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) 初始化仓库、在仓库内产出 HTML 及关联文件,并通过 git commit / git push / `+release-create` / `+release-get` 发布部署。任何 git 操作(clone / pull / push)报错时,先执行 `lark-cli apps +git-credential-init --app-id --as user` 刷新本地 Git 凭证,再重试原 git 命令。如果刷新凭证也失败,**停止并向用户报告**:原始 git 错误、凭证刷新失败原因,以及是否可能是当前环境(操作系统、沙箱)限制导致(如 macOS Keychain 在沙箱中不可用、Linux 加密文件目录不可写等)。不要改走 `+html-publish`,也不要把 `+html-publish` 当作本地开发链路的 fallback。
+- 创意模式(html)应用的链接格式为 `https://{租户域名}/page/{meta_token}`,**开发态和发布态是同一个链接**(区别于 full_stack 应用两者分开)。此链接形似飞书文档链接。`+get --app-id ` 可获取应用信息(含 `app_id`),`+get --app-id ` 可获取 `meta_token`。看到 `/page/xxx` 链接时,它是妙搭创意模式应用,不要当成飞书文档跳过。
## 平台资源与应用源码边界
+- `apps` 命令的 `--path`、`--file`、`--output` 等路径参数只接受当前工作目录(cwd)下的相对路径,传绝对路径会报错。如果目标文件不在 cwd 下,先 `cd` 到目标目录再执行命令。
+- 图片、字体、音视频等资源型文件属于平台资源,不应提交到 git 仓库、引用本地路径或以 base64 内联到源码中。先通过 `lark-cli apps +file-upload --app-id --file ` 上传到应用文件存储,拿到返回的远端 URL 后在代码中引用。上传返回的链接按 app 隔离,不同应用必须各自重新上传,不能跨应用复用同一链接。详情读 [`lark-apps-file.md`](references/lark-apps-file.md)。
- `apps +role-*` 只管理平台角色资源;修改已初始化应用的源码(包括当前目录已经是应用项目)时,先查看工作区 `.agents/skills/`,完整读取与任务匹配的领域 skill,再按其路由读取所需 reference。角色鉴权或运行态角色管理读应用内 `authz-guide`,不能用本 skill 的平台命令参考推断运行时合同。
- `lark-cli` 只用于开发过程中的平台资源核验或变更。应用运行时代码必须使用工程内领域 skill 规定的 SDK,禁止通过 `exec` 或子进程调用 `lark-cli`。
- 平台回读出的当前资源 ID、名称和成员只用于事实核验,不自动构成业务策略;除非需求或应用内领域 skill 明确定义,禁止把当前样本硬编码成 allowlist、denylist、只读集合或权限规则。
-- 实现领域 SDK 时,以实际包导出的类型和应用内领域 reference 记录的入参、响应路径为准;禁止修改 ambient `.d.ts`、补造宽松类型或强制断言,让猜测的 SDK 结构仅在本地“编译通过”。
+- 实现领域 SDK 时,以实际包导出的类型和应用内领域 reference 记录的入参、响应路径为准;禁止修改 ambient `.d.ts`、补造宽松类型或强制断言,让猜测的 SDK 结构仅在本地"编译通过"。
- typecheck/build 成功不等于合同正确。交付前逐项核对每个 SDK 调用的入参、响应取值路径和策略分支;涉及更新、删除等不同动作时,分别验证各自动作所需的完整状态,不能复用更弱的前置判断。
- 源码任务交付前确认新增页面、Controller、Module 已接入真实 router/bootstrap,并运行项目现有 typecheck/build;只创建未接线文件不算完成。
- `+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限;应用协作者/开发权限仍需使用妙搭 Web。自动化触发器请用 `+automation-*`(见「意图路由」)。
@@ -93,6 +98,12 @@ lark-cli auth login --domain apps
`app_id` 必须是妙搭应用 ID(`app_` 开头)。`cli_` 开头的是飞书应用 ID(lark-cli 自身鉴权用,如 `auth status` 输出的 `appId`),**绝不能**传给任何 `apps +*` 命令。
+如果你拿到的是 `https://{租户域名}/page/` 这类链接里的 meta_token — 这是创意模式应用的 **meta_token**(链接形似飞书文档),先用 `+get` 解析出 `app_id`。如果拿到的不是链接、也不是 `app_` 开头,可能是裸 meta_token,同样先用 `+get --app-id ` 尝试获取应用信息,能正常返回则说明是 meta_token:
+
+```bash
+lark-cli apps +get --app-id -q '.data.app.app_id'
+```
+
按顺序尝试,不要一上来要求用户手填:
1. 用户给出 `app_xxx` 或妙搭链接(如 `/app/app_xxx`)时直接提取。
@@ -107,4 +118,4 @@ lark-cli auth login --domain apps
## 高影响动作:确认与预授权
- **预授权判定**:判断用户是否表达了"放手做完、不用中途逐步问我"的意图——明确免确认(如"别问 / 直接做 / 自己定"),或要求一气呵成做到完成(如"做完部署上线给我")。是 → 整个流程按合理默认往下走、不再逐步确认(含 clone 到派生目录、发布等);否 → 缺失参数(如目录)该问就问、高影响动作先确认。
-- **禁止预授权判定底线**(即便已预授权也不豁免):① 会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))先 `--dry-run` 确认;② `+role-delete`、`+role-member-remove --all`、批量移除成员必须先确认 app、role、成员范围和后果,不能从泛化"直接做"推导出 `--yes`;命令式“删除/移除某对象”只确定操作目标,不等于用户已确认不可逆后果,未明确确认时应在说明影响后停下请求确认;③ `+html-publish` 体积超限时(判据见 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)),立即停止并转述超限项。
+- **禁止预授权判定底线**(即便已预授权也不豁免):① 会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))先 `--dry-run` 确认;② `+role-delete`、`+role-member-remove --all`、批量移除成员必须先确认 app、role、成员范围和后果,不能从泛化"直接做"推导出 `--yes`;命令式"删除/移除某对象"只确定操作目标,不等于用户已确认不可逆后果,未明确确认时应在说明影响后停下请求确认;③ `+html-publish` 体积超限时(判据见 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)),立即停止并转述超限项。
diff --git a/skills/lark-apps/creative-design/agents/assets/vision-probe.png b/skills/lark-apps/creative-design/agents/assets/vision-probe.png
new file mode 100644
index 000000000..aabbb7397
Binary files /dev/null and b/skills/lark-apps/creative-design/agents/assets/vision-probe.png differ
diff --git a/skills/lark-apps/creative-design/agents/fork-verifier-agent.md b/skills/lark-apps/creative-design/agents/fork-verifier-agent.md
new file mode 100644
index 000000000..2ed155f5a
--- /dev/null
+++ b/skills/lark-apps/creative-design/agents/fork-verifier-agent.md
@@ -0,0 +1,71 @@
+# Fork verifier (read-only)
+
+You are a **read-only** verification subagent spawned to check a design
+deliverable the main agent just built or edited. Your **only** job: load that
+deliverable, verify it, and report a single verdict — `done` or `needs_work` —
+back to the main agent. **You must not modify, create, or delete any file**,
+edit the source, build, or take any other action. You read, probe, and report —
+nothing else. Resolve every tool named below to your harness's equivalent via
+its reference doc (`references/.md`): a generic action like "show the
+file" or "evaluate JS in-page" maps to your harness's preview / eval tool.
+
+## Input
+
+You are given the **project directory**, the **path(s) of the HTML file(s)** the
+main agent built or edited, and the served
+`http://localhost:/.html` URL to load (always over HTTP —
+never `file://`). The caller may also include an explicit image-input status:
+`image input supported` or `image input unsupported`. You do **not** inherit the
+main agent's transcript; verify only what these inputs point at.
+
+## What to do
+
+1. Show the file the main agent built/edited (your harness's show-file / preview
+ tool — upstream `show_html`).
+2. Read the console / webview logs (upstream `get_webview_logs`) — console
+ errors? failed loads?
+3. Screenshot — layout / spacing / type / content look right? Skip screenshot
+ reads only when the caller explicitly says image input is unsupported; in
+ that case continue with console and JS/DOM checks and state that visual
+ screenshot review was skipped.
+4. Evaluate JS in-page (upstream `eval_js`) to probe if something seems off. For
+ overflow/alignment issues, diagnose the constraint before reporting:
+
+ ```js
+ const el = document.querySelector('...'); const p = el.parentElement;
+ const pick = (e, cs) => ({rect: e.getBoundingClientRect(), boxSizing: cs.boxSizing, display: cs.display, position: cs.position, width: cs.width, height: cs.height, minHeight: cs.minHeight, flexDirection: cs.flexDirection});
+ JSON.stringify({el: pick(el, getComputedStyle(el)), parent: pick(p, getComputedStyle(p))});
+ ```
+
+ Include the result in your `needs_work` description so the main agent fixes
+ the root cause (box-sizing, flex `min-height:auto`, percentage height with no
+ resolved parent height), not the pixel symptom.
+5. If the authored source uses `var(--*)`: evaluate JS to collect every custom
+ property DEFINED in the loaded stylesheets (any selector / `@layer` /
+ `@media`, not just `:root`):
+
+ ```js
+ const defined = new Set();
+ const walk = rs => { for (const r of rs||[]) { if (r.style) for (const p of r.style) if (p.startsWith('--')) defined.add(p); try { walk(r.cssRules || r.styleSheet?.cssRules); } catch {} } };
+ for (const ss of document.styleSheets) try { walk(ss.cssRules); } catch {}
+ JSON.stringify([...defined]);
+ ```
+
+ Then grep the authored file for `var\(--[a-zA-Z0-9_-]+` and report any
+ referenced name not in the defined set as unresolved.
+6. Report your verdict — `done` or `needs_work` with a description — as your
+ **final message** back to the main agent (upstream
+ `verification_feedback({verdict, description})`). The verdict IS the
+ deliverable; do not end on a prose summary with no verdict.
+
+## Rules
+
+- **Read-only, always.** Never write or edit files, build, serve, or run write
+ scripts. The upstream `write_file`, `str_replace_edit`, `show_to_user`,
+ `update_todos`, and `run_script` are all off-limits — if something is wrong you
+ *report* it; the main agent fixes it and re-runs you.
+- **`needs_work` = REAL problems only** — broken layout, console errors, missing
+ content, unresolved `var(--*)` tokens. Not nitpicks.
+- **The verdict is the only exit.** A text-only reply with no `done` /
+ `needs_work` verdict is a dead end — always end with the verdict + description.
+- Always load over the served `http://localhost:…` URL, never `file://`.
diff --git a/skills/lark-apps/creative-design/agents/vision-probe-agent.md b/skills/lark-apps/creative-design/agents/vision-probe-agent.md
new file mode 100644
index 000000000..b171c65d5
--- /dev/null
+++ b/skills/lark-apps/creative-design/agents/vision-probe-agent.md
@@ -0,0 +1,41 @@
+# Vision probe (read-only)
+
+You are a **read-only** capability probe spawned before a design task tries to
+read or inspect screenshots. Your only job is to determine whether this Claude
+Code session's current model/provider can accept image input.
+
+## Input
+
+You are given the absolute path to a tiny PNG probe image — the committed asset
+that ships with this skill, usually:
+
+```text
+/agents/assets/vision-probe.png
+```
+
+## What to do
+
+1. Try to read/view the PNG with the harness's normal image-reading capability.
+ The probe image is a small colorful square with a dark X/border so successful
+ image input should be recognizable without needing any project context.
+2. If the image is visible to you, final-answer exactly:
+
+ ```text
+ VISION_OK
+ ```
+
+3. If the image cannot be read, the provider rejects image input, a tool fails,
+ or you are not sure, final-answer exactly:
+
+ ```text
+ VISION_UNSUPPORTED
+ ```
+
+## Rules
+
+- **Read-only, always.** Do not write, edit, delete, serve, preview, or inspect
+ any project files.
+- Do not read real design screenshots. This probe must touch only the tiny probe
+ image path provided by the main agent.
+- Do not explain your reasoning in the final response. The main agent needs one
+ exact token only: `VISION_OK` or `VISION_UNSUPPORTED`.
diff --git a/skills/lark-apps/creative-design/assets/index.html b/skills/lark-apps/creative-design/assets/index.html
new file mode 100644
index 000000000..b845ff6c3
--- /dev/null
+++ b/skills/lark-apps/creative-design/assets/index.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/skills/lark-apps/creative-design/creative-design.md b/skills/lark-apps/creative-design/creative-design.md
new file mode 100644
index 000000000..fc9cc83d3
--- /dev/null
+++ b/skills/lark-apps/creative-design/creative-design.md
@@ -0,0 +1,239 @@
+---
+name: creative-design
+description: 以自包含 HTML 创建精致的设计产物:UI mockup、可交互原型、线框图(wireframe)、落地页、仪表盘、应用屏幕、移动 App、幻灯片 deck(即 PPT / PowerPoint 演示文稿)、动画视频(motion graphics、产品演示 Demo 动画、数据动画)、可视化报告 / 信息图(infographic)/ 视觉长图与视觉探索。只要用户要求为界面、产品屏幕、用户流程、内容版式、视觉产物或 pitch/deck 概念进行 design、mock up、prototype、wireframe、可视化、动画/动效、探索或制作 PPT/deck——即便他们没有说"设计"二字——就使用本 skill。Harness 无关:适用于 Aily、Claude Code、Codex Agent 及类似的具备文件能力的 agent。
+---
+
+## 目录结构与运行环境
+本 skill 附带以下资源,路径均相对于本文件所在目录:
+
+- `references/.md` — 媒介专属技能 prompt(如 `frontend-design.md`、`hi-fi-design.md`、`charts.md` 等;见文末「Skills 元信息」的完整列表)。与下方 harness 工具映射表同在 `references/` 目录。
+- `starter-components/` — 现成的 HTML/JS/JSX 脚手架(`design-canvas.jsx`、`deck-stage.js`、`ios-frame.jsx`、`android-frame.jsx`、`tweaks-panel.jsx`、`macos-window.jsx`、`browser-window.jsx`、`animations.jsx`)。见下文「Starter Components」。
+- `references/.md` — **harness 专属工具映射表**(`claude.md`、`codex.md`、`aily.md`)。本文行文使用的是 harness 无关的 web 工具名——`ask_user_question`、`copy_starter_component`、`invoke_skill("X")`、`generate_image`、`search_images`、展示文件等——**动手前先读取与你当前运行环境对应的 `references/.md`,把这些名字映射成你 harness 里的真实工具**。例如在 Claude Code 里 `ask_user_question` → `AskUserQuestion`、`copy_starter_component` → `Bash cp <本 skill 所在目录>/starter-components/ .`、`invoke_skill("X")` → `Read references/.md`。
+- `assets/index.html` — React + Babel 的 HTML 起步模板(锁定版本 script 标签 + `#root` 挂载点),见下文「React + Babel」。
+
+## 工作流
+1. 理解用户需求。对全新或含糊的工作,提出澄清性问题。弄清输出物、精细度(fidelity)、选项数量、约束条件,以及涉及的 UI kit 与品牌。
+2. 探索所提供的资源。附件、文档链接、网页 URL 都要在动手前解析完(见「输入资料解析」)。
+3. 列出 todo 清单。
+4. 为本次任务创建独立的任务目录——多个任务会在同一个根目录下执行,直接写根目录会互相覆盖、文件串台;每个任务目录是一个**独立的妙搭应用仓库**——新任务先用 `+create` 建应用、再 `+init --app-id --dir <任务目录>` 初始化仓库(会自动 clone 并切到 `sprint/default`,命令见「发布」前提),独立发布互不影响。把资源复制进任务目录,在其中创建交付物。用图片素材提升美观度与丰富度、或需要有依据的内容时,按「图像素材与外部信息」补充。
+5. (如有)自检React + Babel路径是否正确;ReactDOM.createRoot 是否参数正确,对应元素是否存在
+6. 收尾:提交你的改动。
+7. 发布:把产物发布到妙搭拿到可访问链接(见下方「发布」)。写完不发布,用户拿不到线上链接。
+8. 极其简短地总结——只讲注意事项与后续步骤,并给出发布后的可访问链接。
+
+鼓励你并发调用文件探索工具以提升效率。
+
+## 提问
+默认基于用户给的信息、项目上下文和合理假设直接开始,不为收集偏好而打断。只有当一个决策同时满足两条,使用可用的 向用户提问的 工具向用户提问:① 用户没说、且从 prompt / PRD / 截图 / 代码库 / 品牌资料也推不出;② 猜错要推倒重来(承重决策,下游都建在它上面)。两条只要有一条不成立——能合理推断,或猜错只是局部返工——就直接做。
+
+承重、推不出就必须先问的:交付媒介 / 格式(报告 vs deck vs 看板);视觉 / 美学方向(从零起的项目、且资料里推不出一个有把握不返工的方向时);大体量交付(整套 deck、多页产物)的受众 / 目的与核心范围。
+局部、给默认直接做的:变体数量与探索维度、界面文案、占位与示例内容、单屏 / 单组件的处理与密度——给合理默认(变体默认摆 2-3 个有清晰差异的方案),让用户在产出上重定向,不为它们提问。
+
+例如:
+
+- "做一份关于 X 的报告/材料"但没说格式 → 媒介推不出且承重,先确认交付格式(幻灯片 vs. 视觉报告 vs. 仪表盘),再问格式相关的问题。
+- 为附带的 PRD 做一套 deck → PRD 能推出受众 / 场景就直接做;只有受众、篇幅推不出且影响全局时才问。
+- 用这份 PRD 为 Eng All Hands 做一套 10 分钟的 deck → 无需提问;信息已足够。
+- 把这张截图变成交互原型 → 只有当图片无法说明预期行为时才提问。
+- 做 6 页关于黄油历史的幻灯片 → 媒介、页数已定,直接开工;风格能从主题推断就定,推不出再问。
+- 为我的外卖 app 的 onboarding 做一套原型 → 按常见 onboarding 流程直接做;只问会阻塞产出的承重问题。
+
+当交付格式本身不明确时——用户只说了一个成果("一份报告""材料""一份摘要")却没说媒介——先解决格式,再讨论任何与格式相关的细节。
+
+问出好问题至关重要。技巧:
+
+- 通常一轮聚焦提问就够;把承重的未知一次问齐,不要挤牙膏式多轮打断。
+- 只问推不出的;能从 PRD、截图、代码库、品牌资产、现有页面和用户原话推断的,先推断,并在产出里说明你的假设。
+
+## 输入资料解析
+用户给的附件、文档链接和 URL 是设计的输入,必须在动手前解析完——数据看板、报告和基于文档的 deck 全都建立在源资料之上,跳过这一步产出的内容只能靠编造。按输入形态处理:
+
+- **数据文件(csv / json / xlsx)**——先看结构(列名、字段类型、行数)和样本行,再决定信息层级与图表选型;指标一律用脚本从源数据计算,不要目测。
+- **压缩包(zip)**——先解压到临时目录,逐个查看内容物,再按各自类型处理。
+- **文档(docx / pdf / 论文 / 需求文档)**——用当前 harness 的文档解析能力读取**全文**(映射见 `references/.md`;Aily 原生支持解析 Word / PDF 等二进制文件),不要只读开头就动手。
+- **飞书云文档 / 多维表格链接**——用 `lark-cli` 读取内容(云文档 / 多维表格相关命令,不确定用法先查 `--help`);`lark-cli` 不可用时向用户说明并请其导出或粘贴,不要凭标题猜内容。
+- **网页 URL**——用 `web_fetch` 抓取全文后再产出;抓取失败就告知用户,不要凭 URL 和常识编写。
+
+## 如何开展设计工作
+动手前先读取 **`./references/frontend-design.md`** 确立视觉方向——它教你如何果断做出有意图、不落模板俗套的美学抉择:有品牌或既有 UI 时对齐现有视觉语言,从零起步时据主题 / 材料立一个契合的方向。当媒介专属 skill 内的指令与通用设计规则冲突时,以媒介 skill 内的指令为准——这是规则内容的优先级,不改变「该加载 / 调用哪些 skill」。
+
+当用户请你做高保真 UI mockup、界面设计或带多方案的视觉探索时,开始之前先读取 **`./references/hi-fi-design.md`**——它涵盖了设计流程、获取设计上下文、提问以及呈现多个方案。
+
+一次设计探索的输出是单个 HTML 文档。根据你所探索的内容选择呈现格式:
+
+- **静态视觉 / 设计稿 / 多方案探索**(颜色、字体、单个元素、整屏 UI、流程关键帧)→ 通过 `starter-components/design-canvas.jsx` starter component 把各方案铺陈在画布上。除非用户明确要求可点击 / 可交互,否则不要把设计稿升级成点击原型。
+- **用户明确要求可交互的流程或产品 demo** → 将整个产品做成高保真可点击原型,并把关键选项以 Tweak 形式暴露出来。可交互原型禁止使用 `starter-components/design-canvas.jsx`、`` 或画布外壳包裹;它应该作为真实应用界面直接运行。
+
+这两者可以组合,但只限静态设计探索。已经做好的**可交互原型**如果用户接着想探索多个方向,用页内开关、路由、Tabs、Tweak 或模式切换承载变体;不要把交互原型放进 design-canvas 画布,也不要用 `` 并排包裹。
+
+当用户要求新版本或改动时,把它们作为 TWEAKS 加到原件上;拥有一个可切换不同版本开关的主文件,优于拥有多个文件。
+
+## 默认美学指令
+如果用户没给参考或艺术方向:能从主题、材料或场景推断出一个有把握、不会返工的视觉方向,就主动确定,并在设计中体现假设;如果推不出、又是从零起的项目,先用 `ask_user_question` 问清偏好的调性、受众、颜色、字体、情绪等再动手——不要在推不出方向时硬选,slop 就是这么来的。
+
+定下视觉方向后(无论是推断还是问来的),创建设计时遵循以下指引:
+
+- **字体与排版。** 选择与主题、媒介和场景匹配的少量字体,并通过字号、字重、字宽、行长、语义断行、数字样式和文字位置建立清晰层级与视觉节奏;不依赖增加字体数量制造变化。
+- **背景与色彩体系。** 确定主色调,并建立与主题协调的中性基底、主题色和必要的章节/语义色。背景不局限于纯黑、纯白或单一色调,可以根据内容属性、页面角色和叙事节点使用不同色调、主题色底、局部色域、图片或图形背景。
+- **色彩一致性。** 一致性来自共享色板、字体、栅格、图形语言和明确的颜色关系,不要求所有页面使用相同背景。颜色变化应帮助识别章节、信息层级和重点,避免无语义地逐页随机换色。
+- **强调色。** 使用数量克制、关系协调的强调色,并根据背景、信息层级和色彩语义调整明度与彩度。图表、状态和章节色需要清楚可区分,但应属于同一视觉体系。
+- **中性色。** 黑、白、灰可以带有与主题协调的细微色相,避免把纯黑白或低饱和配色作为所有专业场景的默认答案。
+- **视觉复杂度。** 视觉丰富度应服务内容。不要添加无信息价值的装饰,也不要把"克制"理解为单调、大量留白、缺少图片图表或所有页面使用同一种构图。
+
+关键:如果已给出其他美学指令(如参考图、品牌体系、设计规范或媒介专属 skill),或项目中已有文件,则完全忽略默认美学。
+
+## 图像素材与外部信息
+图片素材能显著提升产物的美观度与丰富度——不要默认只用纯 CSS/SVG 撑起全部视觉。为氛围、质感和视觉节奏而配图是正当用途,不需要等到"内容必须有图"才配图。选择工具的判断规则很简单:**需要真实图片就搜索,需要丰富美观的图片就生成**。当前 harness 若提供以下能力(映射见 `references/.md`;没有对应工具就跳过,用内联 SVG / CSS 图形兜底),在合适的位置主动使用:
+
+- **`generate_image`(AI 图片生成)**——美化、氛围类配图一律走生成:hero 图、插画、照片质感背景、章节题图、空状态插图、信息图(infographic)、产品/场景示意图等任何能让页面更好看的位置,用文生图直接生成;有品牌参考图或用户素材时用图生图对齐既有视觉语言;多屏 / 多页需要风格统一、角色连贯的插画体系时用组图一次生成整个序列;对已有图片做局部调整用图片编辑。生成 prompt 里写清风格、构图、配色与光线,让产出与已确立的视觉方向一致,而不是各自为政。
+- **`search_images`(图片搜索)**——需要真实图片时走搜索:真实存在的实物、产品、地点、人物、logo、截图等生成会失真或造假的素材,以及确立视觉方向时按关键词找参考图(同类产品界面、风格 moodboard)。直接引用搜索结果时注意来源与版权。
+- **`web_search` / `web_fetch`(联网搜索)**——内容需要真实事实、数据、案例或时效性信息时先搜再写,不要编造(见「内容准则」:涉及新增事实、数据时要有依据)。调研型产出(行业研究、政策梳理、竞争格局类 deck / 报告)要先做多轮搜索,把事实、数字与来源收集齐并标注出处,再进入设计。
+- **视频素材**——需要嵌入公开视频(培训短片、案例视频等)时,用联网搜索找到可公开访问的视频页面或可嵌入链接,以 `