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 (`![name](url)`; 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`, `![p](url)
**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 (`![name](url)`; 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`, `![p](url)
**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 ![a](https://example.com/a.png) more ![b](data:image/png;base64,xx)" + 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", "![pic](./pic.png)", + "--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", "![pic](./pic.png)", + "--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", "![p](/etc/hosts)", + "--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 / 报告)要先做多轮搜索,把事实、数字与来源收集齐并标注出处,再进入设计。 +- **视频素材**——需要嵌入公开视频(培训短片、案例视频等)时,用联网搜索找到可公开访问的视频页面或可嵌入链接,以 `