mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
44 Commits
v1.0.73
...
sun/tempv2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f13ff1b267 | ||
|
|
20cf972725 | ||
|
|
4807283368 | ||
|
|
d2bb36591f | ||
|
|
5a54bc07db | ||
|
|
a528b3cb69 | ||
|
|
f0176af330 | ||
|
|
715aa8d960 | ||
|
|
ebc0c53ab5 | ||
|
|
1e682bd97c | ||
|
|
70424c486c | ||
|
|
b8f56dbc0b | ||
|
|
c74d9b63fb | ||
|
|
67015eef8e | ||
|
|
af8507ea8e | ||
|
|
02c2ebcf7c | ||
|
|
abf6f99d7e | ||
|
|
8ba910eb9f | ||
|
|
78bf126bb0 | ||
|
|
4eefe32c1a | ||
|
|
8f6f8eb0fc | ||
|
|
80323bb464 | ||
|
|
0a33bd7c57 | ||
|
|
aafaed06a7 | ||
|
|
54ddcf490b | ||
|
|
bb246b591f | ||
|
|
fc2761d16b | ||
|
|
409a3172da | ||
|
|
483aadee3b | ||
|
|
e43f497650 | ||
|
|
990d633c07 | ||
|
|
d4168ab84f | ||
|
|
12ca42c953 | ||
|
|
d382ee9053 | ||
|
|
daaacb4977 | ||
|
|
680501c1df | ||
|
|
6675e3c247 | ||
|
|
7b48709438 | ||
|
|
c876841106 | ||
|
|
4c1a92caa6 | ||
|
|
577ff035c3 | ||
|
|
4b4ca4283a | ||
|
|
ad4a6d68c7 | ||
|
|
d8fb368ce4 |
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -99,6 +99,22 @@ jobs:
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
windows-compat:
|
||||
needs: fast-gate
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Fetch meta data
|
||||
run: python scripts/fetch_meta.py
|
||||
- name: Run Windows compatibility tests
|
||||
run: go test -count=1 -timeout=5m . ./shortcuts/doc/...
|
||||
|
||||
lint:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
103
.github/workflows/release.yml
vendored
103
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,6 +42,7 @@ tests/mail/reports/
|
||||
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-cli-e2e-test/reports/
|
||||
.lark-slides/
|
||||
/notes/
|
||||
/minutes/
|
||||
|
||||
@@ -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
|
||||
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -2,6 +2,65 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
@@ -1579,6 +1638,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
|
||||
9
Makefile
9
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
396
cmd/api/api_paginate_test.go
Normal file
396
cmd/api/api_paginate_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
cmd/auth/testmain_test.go
Normal file
46
cmd/auth/testmain_test.go
Normal file
@@ -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)
|
||||
}
|
||||
105
cmd/build_bench_test.go
Normal file
105
cmd/build_bench_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// BenchmarkBuild_Default measures the per-Build cost for the default
|
||||
// configuration (service commands + shortcuts + plugins + strict mode).
|
||||
// This is the hot-path baseline for repeated Build invocations.
|
||||
func BenchmarkBuild_Default(b *testing.B) {
|
||||
// Warm one-time caches first
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
runtime.GC()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuild_WithoutServiceCommands measures the Build cost without
|
||||
// service command registration. The delta from Default gives the
|
||||
// service-command registration cost.
|
||||
func BenchmarkBuild_WithoutServiceCommands(b *testing.B) {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands())
|
||||
runtime.GC()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands())
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuild_WithoutPlugins measures the Build cost without plugins.
|
||||
// The delta from Default gives the plugin + policy + hook cost.
|
||||
func BenchmarkBuild_WithoutPlugins(b *testing.B) {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
||||
runtime.GC()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuild_WithoutServiceAndPlugins measures the Build cost with
|
||||
// neither service commands nor plugins. This isolates the base cost
|
||||
// (root command + builtins + shortcuts).
|
||||
func BenchmarkBuild_WithoutServiceAndPlugins(b *testing.B) {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands(), WithoutPlugins())
|
||||
runtime.GC()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands(), WithoutPlugins())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuild_CommandTreeStats counts the total number of commands,
|
||||
// runnable commands, and flags in the default build. This gives us
|
||||
// the scale of the command tree to reason about optimization targets.
|
||||
func TestBuild_CommandTreeStats(t *testing.T) {
|
||||
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
||||
|
||||
var totalCmds, runnableCmds, groupCmds int
|
||||
var totalFlags int
|
||||
|
||||
var walk func(cmd *cobra.Command)
|
||||
walk = func(cmd *cobra.Command) {
|
||||
totalCmds++
|
||||
if cmd.RunE != nil || cmd.Run != nil {
|
||||
runnableCmds++
|
||||
} else {
|
||||
groupCmds++
|
||||
}
|
||||
if cmd.Flags() != nil {
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
totalFlags++
|
||||
})
|
||||
}
|
||||
for _, child := range cmd.Commands() {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
|
||||
t.Logf("Command tree stats:")
|
||||
t.Logf(" Total commands: %d", totalCmds)
|
||||
t.Logf(" Runnable commands: %d", runnableCmds)
|
||||
t.Logf(" Group commands: %d", groupCmds)
|
||||
t.Logf(" Total flags: %d", totalFlags)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
400
cmd/service/service_paginate_test.go
Normal file
400
cmd/service/service_paginate_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
39
cmd/service/testmain_test.go
Normal file
39
cmd/service/testmain_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
46
cmd/testmain_test.go
Normal file
46
cmd/testmain_test.go
Normal file
@@ -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)
|
||||
}
|
||||
23
cmd/update/testmain_test.go
Normal file
23
cmd/update/testmain_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -42,6 +42,15 @@ type FileIO interface {
|
||||
Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error)
|
||||
}
|
||||
|
||||
// TempDirFileCreator is an optional FileIO capability for atomically creating
|
||||
// a unique directory and an empty named file inside it. The directory pattern
|
||||
// follows os.MkdirTemp semantics: the last '*' is replaced with a random
|
||||
// value. Implementations return a relative file path that can be passed back
|
||||
// to FileIO.
|
||||
type TempDirFileCreator interface {
|
||||
CreateTempDirFile(directoryPattern, fileName string) (string, error)
|
||||
}
|
||||
|
||||
// FileInfo is a minimal subset of os.FileInfo covering actual CLI usage.
|
||||
// os.FileInfo satisfies this interface.
|
||||
type FileInfo interface {
|
||||
|
||||
1
go.mod
1
go.mod
@@ -18,6 +18,7 @@ require (
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sync v0.15.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -131,6 +131,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
|
||||
23
internal/auth/testmain_test.go
Normal file
23
internal/auth/testmain_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"})
|
||||
|
||||
36
internal/cmdutil/localfile.go
Normal file
36
internal/cmdutil/localfile.go
Normal file
@@ -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)
|
||||
}
|
||||
96
internal/cmdutil/localfile_test.go
Normal file
96
internal/cmdutil/localfile_test.go
Normal file
@@ -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)
|
||||
}
|
||||
30
internal/cmdutil/testmain_test.go
Normal file
30
internal/cmdutil/testmain_test.go
Normal file
@@ -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)
|
||||
}
|
||||
23
internal/event/testmain_test.go
Normal file
23
internal/event/testmain_test.go
Normal file
@@ -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)
|
||||
}
|
||||
28
internal/keychain/testmain_test.go
Normal file
28
internal/keychain/testmain_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
336
internal/output/emitter.go
Normal file
336
internal/output/emitter.go
Normal file
@@ -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
|
||||
}
|
||||
350
internal/output/emitter_contract_test.go
Normal file
350
internal/output/emitter_contract_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
827
internal/output/emitter_legacy_compat_test.go
Normal file
827
internal/output/emitter_legacy_compat_test.go
Normal file
@@ -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": "<p>a&b</p>"}
|
||||
},
|
||||
raw: true,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "format_raw_json_preserves_html",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"html": "<p>a&b</p>"}
|
||||
},
|
||||
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": "<p>a&b</p>"}}
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
107
internal/output/testdata/runtime_context_legacy.golden.json
vendored
Normal file
107
internal/output/testdata/runtime_context_legacy.golden.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
41
internal/output/testdata/write_success_envelope_legacy.golden.json
vendored
Normal file
41
internal/output/testdata/write_success_envelope_legacy.golden.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
72
internal/registry/registrytest/fixture_meta.json
Normal file
72
internal/registry/registrytest/fixture_meta.json
Normal file
@@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
146
internal/registry/registrytest/registrytest.go
Normal file
146
internal/registry/registrytest/registrytest.go
Normal file
@@ -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
|
||||
}
|
||||
229
internal/registry/registrytest/registrytest_test.go
Normal file
229
internal/registry/registrytest/registrytest_test.go
Normal file
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
27
internal/registry/testmain_test.go
Normal file
27
internal/registry/testmain_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -17,6 +17,13 @@ func SafeInputPath(path string) (string, error) {
|
||||
return localfileio.SafeInputPath(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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -5,10 +5,14 @@ package localfileio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -30,6 +34,8 @@ func init() {
|
||||
// and atomic writes are handled internally.
|
||||
type LocalFileIO struct{}
|
||||
|
||||
var _ fileio.TempDirFileCreator = (*LocalFileIO)(nil)
|
||||
|
||||
// Open opens a local file for reading after validating the path.
|
||||
func (l *LocalFileIO) Open(name string) (fileio.File, error) {
|
||||
safePath, err := SafeInputPath(name)
|
||||
@@ -62,6 +68,46 @@ func (l *LocalFileIO) ResolvePath(path string) (string, error) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// CreateTempDirFile atomically creates a unique directory in the current
|
||||
// working directory, then creates the requested empty file inside it.
|
||||
func (l *LocalFileIO) CreateTempDirFile(directoryPattern, fileName string) (string, error) {
|
||||
if err := validateTempDirectoryPattern(directoryPattern); err != nil {
|
||||
return "", &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
if err := validateTempFileName(fileName); err != nil {
|
||||
return "", &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
tempDir, err := vfs.MkdirTemp(".", directoryPattern)
|
||||
if err != nil {
|
||||
return "", &fileio.MkdirError{Err: err}
|
||||
}
|
||||
path := filepath.Join(tempDir, fileName)
|
||||
tempFile, err := vfs.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
_ = vfs.RemoveAll(tempDir)
|
||||
return "", &fileio.WriteError{Err: err}
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
_ = vfs.RemoveAll(tempDir)
|
||||
return "", &fileio.WriteError{Err: fmt.Errorf("close temporary file: %w", err)}
|
||||
}
|
||||
return filepath.Join(filepath.Base(tempDir), fileName), nil
|
||||
}
|
||||
|
||||
func validateTempDirectoryPattern(pattern string) error {
|
||||
if strings.TrimSpace(pattern) == "" || strings.ContainsAny(pattern, `/\\`) || strings.Count(pattern, "*") != 1 {
|
||||
return fmt.Errorf("temporary directory pattern must be one non-empty path component containing exactly one '*'")
|
||||
}
|
||||
return charcheck.RejectControlChars(pattern, "temporary directory pattern")
|
||||
}
|
||||
|
||||
func validateTempFileName(fileName string) error {
|
||||
if strings.TrimSpace(fileName) == "" || fileName != filepath.Base(fileName) || strings.ContainsAny(fileName, "/\\\t\r\n") {
|
||||
return fmt.Errorf("temporary file name must be one non-empty path component")
|
||||
}
|
||||
return charcheck.RejectControlChars(fileName, "temporary file name")
|
||||
}
|
||||
|
||||
// Save writes body to path atomically after validating the output path.
|
||||
// Parent directories are created as needed. The body is streamed directly
|
||||
// to a temp file and renamed, avoiding full in-memory buffering.
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -250,6 +252,81 @@ func TestLocalFileIO_ResolvePath_RejectsAbsolute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_CreateTempDirFileIsUniqueUnderConcurrency(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
const count = 32
|
||||
type result struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
results := make(chan result, count)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < count; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
path, err := (&LocalFileIO{}).CreateTempDirFile("川西_*_folder", "川西.xml")
|
||||
results <- result{path: path, err: err}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
seen := make(map[string]struct{}, count)
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
t.Fatalf("CreateTempDirFile failed: %v", result.err)
|
||||
}
|
||||
directory := filepath.Dir(result.path)
|
||||
if filepath.Base(result.path) != "川西.xml" || filepath.Base(directory) != directory ||
|
||||
!strings.HasPrefix(directory, "川西_") || !strings.HasSuffix(directory, "_folder") {
|
||||
t.Fatalf("CreateTempDirFile path = %q, want 川西_<random>_folder/川西.xml", result.path)
|
||||
}
|
||||
if _, ok := seen[directory]; ok {
|
||||
t.Fatalf("CreateTempDirFile returned duplicate directory %q", directory)
|
||||
}
|
||||
seen[directory] = struct{}{}
|
||||
info, err := os.Stat(result.path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat temporary file %q: %v", result.path, err)
|
||||
}
|
||||
if info.Size() != 0 {
|
||||
t.Fatalf("temporary file %q size = %d, want 0", result.path, info.Size())
|
||||
}
|
||||
}
|
||||
if len(seen) != count {
|
||||
t.Fatalf("unique temporary files = %d, want %d", len(seen), count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_CreateTempDirFileRejectsUnsafeComponents(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
fio := &LocalFileIO{}
|
||||
|
||||
for _, test := range []struct {
|
||||
pattern string
|
||||
fileName string
|
||||
}{
|
||||
{pattern: "../lark-doc-*", fileName: "draft.xml"},
|
||||
{pattern: "lark-doc-*", fileName: "../draft.xml"},
|
||||
{pattern: "lark-doc-*", fileName: `folder\draft.xml`},
|
||||
} {
|
||||
if _, err := fio.CreateTempDirFile(test.pattern, test.fileName); !errors.Is(err, fileio.ErrPathValidation) {
|
||||
t.Errorf("CreateTempDirFile(%q, %q) error = %v, want path validation", test.pattern, test.fileName, err)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read work directory: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("invalid inputs created files: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error message consistency ──
|
||||
|
||||
func TestLocalFileIO_ErrorMessages_ContainCorrectFlagName(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -22,6 +23,32 @@ func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -29,7 +56,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
|
||||
}
|
||||
|
||||
8
internal/vfs/localfileio/path_local_other.go
Normal file
8
internal/vfs/localfileio/path_local_other.go
Normal file
@@ -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 }
|
||||
33
internal/vfs/localfileio/path_local_windows.go
Normal file
33
internal/vfs/localfileio/path_local_windows.go
Normal file
@@ -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
|
||||
}
|
||||
27
internal/vfs/localfileio/path_local_windows_test.go
Normal file
27
internal/vfs/localfileio/path_local_windows_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.76",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.76",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
"arm64",
|
||||
"riscv64"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.73",
|
||||
"version": "1.0.76",
|
||||
"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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
108
scripts/release-preflight.js
Normal file
108
scripts/release-preflight.js
Normal file
@@ -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();
|
||||
66
scripts/release-preflight.test.js
Normal file
66
scripts/release-preflight.test.js
Normal file
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。
|
||||
for _, f := range []string{"uploaded-since", "uploaded-until"} {
|
||||
if strings.TrimSpace(rctx.Str(f)) == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -818,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1091,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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1139,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{
|
||||
@@ -1318,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{
|
||||
@@ -1614,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) {
|
||||
@@ -1992,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)
|
||||
}
|
||||
})
|
||||
@@ -2014,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)
|
||||
}
|
||||
})
|
||||
@@ -2035,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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <base_token> --table-id <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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseFieldSearchOptions = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <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 {
|
||||
|
||||
@@ -27,7 +27,9 @@ var BaseFieldUpdate = common.Shortcut{
|
||||
baseHighRiskYesTip,
|
||||
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <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 <base_token> --table-id <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.",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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...),
|
||||
|
||||
@@ -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...),
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -67,6 +67,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"] != "" {
|
||||
@@ -176,7 +195,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())
|
||||
@@ -228,11 +249,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")
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
146
shortcuts/common/localfile.go
Normal file
146
shortcuts/common/localfile.go
Normal file
@@ -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
|
||||
}
|
||||
95
shortcuts/common/localfile_test.go
Normal file
95
shortcuts/common/localfile_test.go
Normal file
@@ -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}
|
||||
}
|
||||
@@ -665,16 +665,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
|
||||
@@ -688,112 +731,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 ──
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
418
shortcuts/doc/docs_script.go
Normal file
418
shortcuts/doc/docs_script.go
Normal file
@@ -0,0 +1,418 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/doc/internal/docxparse"
|
||||
)
|
||||
|
||||
const (
|
||||
docsScriptParse = "parse"
|
||||
docsScriptMarkdownToXML = "markdown-to-xml"
|
||||
docsScriptCreateTempXML = "create-temp-xml"
|
||||
docsScriptTempDirSuffix = "_*_folder"
|
||||
)
|
||||
|
||||
var DocsScript = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+script",
|
||||
Description: "Create a unique temporary XML file, parse and profile local or online documents, or convert Markdown to LarkOpenCLI XML",
|
||||
Risk: "read",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{},
|
||||
ConditionalScopes: []string{
|
||||
"docx:document:readonly",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{
|
||||
Name: "command",
|
||||
Desc: "local document operation",
|
||||
Required: true,
|
||||
Enum: []string{docsScriptParse, docsScriptMarkdownToXML, docsScriptCreateTempXML},
|
||||
},
|
||||
{
|
||||
Name: "content",
|
||||
Desc: "local content for parse or markdown-to-xml; use @relative-file or - for stdin; mutually exclusive with --doc",
|
||||
Input: []string{common.File, common.Stdin},
|
||||
},
|
||||
{
|
||||
Name: "doc",
|
||||
Desc: "online document URL or token for --command parse; mutually exclusive with --content",
|
||||
},
|
||||
{
|
||||
Name: "output",
|
||||
Desc: "local XML output path for markdown-to-xml; omit to return XML in data.xml",
|
||||
},
|
||||
{
|
||||
Name: "file-name",
|
||||
Desc: "portable base name without .xml; create-temp-xml writes <name>_<random>_folder/<name>.xml",
|
||||
},
|
||||
{
|
||||
Name: "overwrite",
|
||||
Type: "bool",
|
||||
Desc: "overwrite an existing --output file",
|
||||
},
|
||||
},
|
||||
Tips: []string{
|
||||
"create-temp-xml atomically creates <file-name>_<random>_folder/<file-name>.xml in the current directory",
|
||||
"parse accepts local --content or an online --doc URL/token and returns only the text and block profile",
|
||||
"markdown-to-xml converts Markdown to LarkOpenCLI XML",
|
||||
"use --output to save converted XML directly and keep stdout compact",
|
||||
},
|
||||
PostMount: installDocsScriptHelp,
|
||||
Validate: validateDocsScript,
|
||||
DryRun: dryRunDocsScript,
|
||||
Execute: executeDocsScript,
|
||||
}
|
||||
|
||||
type docsScriptParseResult struct {
|
||||
Profile docsScriptPublicProfile `json:"profile"`
|
||||
}
|
||||
|
||||
// docsScriptPublicProfile is the stable shortcut response. The parser keeps
|
||||
// the more detailed breakdown internally so it can be exposed later without
|
||||
// changing the counting implementation.
|
||||
type docsScriptPublicProfile struct {
|
||||
WordCount int `json:"word_count"`
|
||||
CharCount int `json:"char_count"`
|
||||
BlockCount int `json:"block_count"`
|
||||
Blocks []docxparse.BlockShare `json:"blocks"`
|
||||
}
|
||||
|
||||
type docsScriptMarkdownResult struct {
|
||||
XML string `json:"xml"`
|
||||
}
|
||||
|
||||
type docsScriptMarkdownFileResult struct {
|
||||
SavedPath string `json:"saved_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
type docsScriptTempXMLResult struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func installDocsScriptHelp(cmd *cobra.Command) {
|
||||
installDocsShortcutHelp("+script")(cmd)
|
||||
cmd.Example = ` lark-cli docs +script --command create-temp-xml --file-name "draft"
|
||||
lark-cli docs +script --command parse --content "@draft.xml"
|
||||
lark-cli docs +script --command parse --content "@draft.md"
|
||||
lark-cli docs +script --command parse --doc "https://example.larksuite.com/docx/doxcn..."
|
||||
lark-cli docs +script --command markdown-to-xml --content "@draft.md" --output "draft.xml"`
|
||||
}
|
||||
|
||||
func validateDocsScript(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
content := strings.TrimSpace(runtime.Str("content"))
|
||||
doc := strings.TrimSpace(runtime.Str("doc"))
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
fileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
if runtime.Str("command") == docsScriptCreateTempXML {
|
||||
switch {
|
||||
case content != "":
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--content is not supported with --command create-temp-xml").WithParam("--content")
|
||||
case doc != "":
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--doc is not supported with --command create-temp-xml").WithParam("--doc")
|
||||
case outputPath != "":
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output is not supported with --command create-temp-xml").WithParam("--output")
|
||||
case runtime.Bool("overwrite"):
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--overwrite is not supported with --command create-temp-xml").WithParam("--overwrite")
|
||||
case fileName == "":
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name is required with --command create-temp-xml").WithParam("--file-name")
|
||||
case runtime.Str("file-name") != fileName:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name must not start or end with whitespace").WithParam("--file-name")
|
||||
default:
|
||||
return validateDocsScriptTempXMLFileName(fileName)
|
||||
}
|
||||
}
|
||||
if fileName != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name is only supported with --command create-temp-xml").WithParam("--file-name")
|
||||
}
|
||||
if content == "" && doc == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --content or --doc is required").WithParams(
|
||||
errs.InvalidParam{Name: "--content", Reason: "provide local document content"},
|
||||
errs.InvalidParam{Name: "--doc", Reason: "provide an online document URL or token"},
|
||||
)
|
||||
}
|
||||
if content != "" && doc != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content and --doc are mutually exclusive").WithParams(
|
||||
errs.InvalidParam{Name: "--content", Reason: "mutually exclusive with --doc"},
|
||||
errs.InvalidParam{Name: "--doc", Reason: "mutually exclusive with --content"},
|
||||
)
|
||||
}
|
||||
if doc != "" {
|
||||
if runtime.Str("command") != docsScriptParse {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--doc is only supported with --command parse").WithParam("--doc")
|
||||
}
|
||||
if _, err := parseDocumentRef(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.EnsureScopes([]string{"docx:document:readonly"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if outputPath == "" {
|
||||
if runtime.Bool("overwrite") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--overwrite requires --output").WithParam("--overwrite")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if runtime.Str("command") != docsScriptMarkdownToXML {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output is only supported with --command markdown-to-xml").WithParam("--output")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
|
||||
WithParam("--output").
|
||||
WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunDocsScript(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if runtime.Str("command") == docsScriptCreateTempXML {
|
||||
fileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Create a random directory and an empty named XML file inside it; no API call is made").
|
||||
Set("command", docsScriptCreateTempXML).
|
||||
Set("directory_pattern", docsScriptTempDirectoryPattern(fileName)).
|
||||
Set("file_name", fileName).
|
||||
Set("xml_file_name", docsScriptXMLFileName(fileName)).
|
||||
Set("creates_file", false).
|
||||
Set("network", false)
|
||||
}
|
||||
if doc := strings.TrimSpace(runtime.Str("doc")); doc != "" {
|
||||
ref, _ := parseDocumentRef(doc)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/docs_ai/v1/documents/:document_id/fetch").
|
||||
Desc("OpenAPI: fetch document for parsing and profiling").
|
||||
Body(docsScriptFetchBody(runtime)).
|
||||
Set("command", runtime.Str("command")).
|
||||
Set("document_id", ref.Token).
|
||||
Set("network", true)
|
||||
}
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Local LarkOpenCLI document parsing or conversion; no API call is made").
|
||||
Set("command", runtime.Str("command")).
|
||||
Set("input_bytes", len(runtime.Str("content"))).
|
||||
Set("network", false)
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
dry.Set("output", outputPath).Set("overwrite", runtime.Bool("overwrite"))
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func executeDocsScript(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
command := runtime.Str("command")
|
||||
content := runtime.Str("content")
|
||||
switch command {
|
||||
case docsScriptCreateTempXML:
|
||||
return createDocsScriptTempXML(runtime)
|
||||
case docsScriptParse:
|
||||
inputParam := "--content"
|
||||
inputLabel := "--content"
|
||||
if strings.TrimSpace(runtime.Str("doc")) != "" {
|
||||
var err error
|
||||
content, err = fetchDocsScriptContent(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inputParam = "--doc"
|
||||
inputLabel = "fetched document content"
|
||||
}
|
||||
profile, err := docxparse.ParseAuto(content)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"could not parse %s as LarkOpenCLI XML or Markdown: %s", inputLabel, err).
|
||||
WithParam(inputParam).
|
||||
WithCause(err)
|
||||
}
|
||||
runtime.OutFormatRaw(docsScriptParseResult{Profile: docsScriptPublicProfile{
|
||||
WordCount: profile.WordCount,
|
||||
CharCount: profile.CharCount,
|
||||
BlockCount: profile.BlockCount,
|
||||
Blocks: profile.Blocks,
|
||||
}}, nil, nil)
|
||||
return nil
|
||||
case docsScriptMarkdownToXML:
|
||||
xml, err := docxparse.MarkdownToXML(content)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"could not convert --content from Markdown to LarkOpenCLI XML: %s", err).
|
||||
WithParam("--content").
|
||||
WithCause(err)
|
||||
}
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
return saveDocsScriptXML(runtime, outputPath, xml)
|
||||
}
|
||||
runtime.OutFormatRaw(docsScriptMarkdownResult{XML: xml}, nil, nil)
|
||||
return nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported --command %q", command).
|
||||
WithParam("--command")
|
||||
}
|
||||
}
|
||||
|
||||
func createDocsScriptTempXML(runtime *common.RuntimeContext) error {
|
||||
creator, ok := runtime.FileIO().(fileio.TempDirFileCreator)
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"the active file I/O provider does not support temporary file creation").
|
||||
WithHint("run this command with the local file I/O provider")
|
||||
}
|
||||
fileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
path, err := creator.CreateTempDirFile(docsScriptTempDirectoryPattern(fileName), docsScriptXMLFileName(fileName))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(path); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"resolve temporary XML path %s: %s", path, err).
|
||||
WithCause(err)
|
||||
}
|
||||
runtime.Out(docsScriptTempXMLResult{
|
||||
Path: path,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsScriptTempXMLFileName(fileName string) error {
|
||||
if fileName != filepath.Base(fileName) || strings.ContainsAny(fileName, "<>:\"/\\|?*\t\r\n") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name must be a portable file name without path separators or reserved characters").WithParam("--file-name")
|
||||
}
|
||||
if err := charcheck.RejectControlChars(fileName, "--file-name"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--file-name").
|
||||
WithCause(err)
|
||||
}
|
||||
if strings.HasSuffix(fileName, ".") || strings.HasSuffix(fileName, " ") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name must not end with a dot or space").WithParam("--file-name")
|
||||
}
|
||||
if strings.EqualFold(filepath.Ext(fileName), ".xml") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name must omit the .xml extension").WithParam("--file-name")
|
||||
}
|
||||
base := strings.ToUpper(strings.SplitN(fileName, ".", 2)[0])
|
||||
if isWindowsReservedFileName(base) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--file-name uses a Windows-reserved device name").WithParam("--file-name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func docsScriptTempDirectoryPattern(fileName string) string {
|
||||
return fileName + docsScriptTempDirSuffix
|
||||
}
|
||||
|
||||
func docsScriptXMLFileName(fileName string) string {
|
||||
return fileName + ".xml"
|
||||
}
|
||||
|
||||
func isWindowsReservedFileName(base string) bool {
|
||||
switch base {
|
||||
case "CON", "PRN", "AUX", "NUL":
|
||||
return true
|
||||
}
|
||||
if len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) {
|
||||
return base[3] >= '1' && base[3] <= '9'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func docsScriptFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": "xml",
|
||||
"extra_param": docsFetchExtraParam,
|
||||
"export_option": map[string]interface{}{
|
||||
"export_block_id": false,
|
||||
"export_style_attrs": false,
|
||||
"export_cite_extra_data": false,
|
||||
},
|
||||
}
|
||||
if lang := resolveFetchLang(runtime); lang != "" {
|
||||
body["lang"] = lang
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func fetchDocsScriptContent(runtime *common.RuntimeContext) (string, error) {
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
|
||||
data, err := doDocAPI(runtime, "POST", apiPath, docsScriptFetchBody(runtime))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
document, ok := data["document"].(map[string]interface{})
|
||||
if !ok || document == nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"document fetch response for --doc is missing document")
|
||||
}
|
||||
content, ok := document["content"].(string)
|
||||
if !ok {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"document fetch response for --doc is missing document.content")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func saveDocsScriptXML(runtime *common.RuntimeContext, outputPath, xml string) error {
|
||||
if !runtime.Bool("overwrite") {
|
||||
if _, err := runtime.FileIO().Stat(outputPath); err == nil {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"output file already exists: %s (use --overwrite to replace)", outputPath).
|
||||
WithParam("--output")
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
|
||||
WithParam("--output").
|
||||
WithCause(err)
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"cannot access output path %s: %s", outputPath, err).
|
||||
WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(xml)),
|
||||
}, strings.NewReader(xml))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
savedPath, err := runtime.ResolveSavePath(outputPath)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"resolve saved XML path %s: %s", outputPath, err).
|
||||
WithCause(err)
|
||||
}
|
||||
runtime.Out(docsScriptMarkdownFileResult{
|
||||
SavedPath: savedPath,
|
||||
SizeBytes: result.Size(),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
660
shortcuts/doc/docs_script_test.go
Normal file
660
shortcuts/doc/docs_script_test.go
Normal file
@@ -0,0 +1,660 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/doc/internal/docxparse"
|
||||
)
|
||||
|
||||
func TestDocsScriptParsesAndProfilesXML(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-test"))
|
||||
source := `<title>标题</title><p>一个苹果是 an apple。</p>`
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", source,
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if !envelope.OK {
|
||||
t.Fatalf("ok = false: %s", stdout)
|
||||
}
|
||||
if len(envelope.Data) != 1 || envelope.Data["profile"] == nil {
|
||||
t.Fatalf("data = %+v, want only profile", envelope.Data)
|
||||
}
|
||||
var profile docsScriptPublicProfile
|
||||
if err := json.Unmarshal(envelope.Data["profile"], &profile); err != nil {
|
||||
t.Fatalf("decode profile: %v", err)
|
||||
}
|
||||
var profileFields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(envelope.Data["profile"], &profileFields); err != nil {
|
||||
t.Fatalf("decode profile fields: %v", err)
|
||||
}
|
||||
if len(profileFields) != 4 || profileFields["breakdown"] != nil {
|
||||
t.Fatalf("profile fields = %+v, want breakdown hidden", profileFields)
|
||||
}
|
||||
if profile.WordCount != 10 || profile.CharCount != 15 || profile.BlockCount != 2 {
|
||||
t.Fatalf("profile = %+v", profile)
|
||||
}
|
||||
if got := blockCount(profile.Blocks, "title"); got != 1 {
|
||||
t.Fatalf("title count = %d, want 1", got)
|
||||
}
|
||||
if got := blockCount(profile.Blocks, "p"); got != 1 {
|
||||
t.Fatalf("p count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptParseAutoDetectsMarkdown(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-auto-markdown"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", "# 标题\n\n- item",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data docsScriptParseResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if envelope.Data.Profile.BlockCount != 3 {
|
||||
t.Fatalf("profile = %+v, want 3 blocks", envelope.Data.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptParsesOnlineDocumentFromToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-token"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnScriptToken/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnScriptToken",
|
||||
"content": `<title>在线文档</title><p>Hello world</p>`,
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--doc", "doxcnScriptToken",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script with token: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data docsScriptParseResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if envelope.Data.Profile.BlockCount != 2 {
|
||||
t.Fatalf("profile = %+v, want 2 blocks", envelope.Data.Profile)
|
||||
}
|
||||
if got := blockCount(envelope.Data.Profile.Blocks, "title"); got != 1 {
|
||||
t.Fatalf("title count = %d, want 1", got)
|
||||
}
|
||||
if got := blockCount(envelope.Data.Profile.Blocks, "p"); got != 1 {
|
||||
t.Fatalf("p count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptParsesOnlineDocumentFromURL(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-url"))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/wikcnScriptURL/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnResolvedScriptURL",
|
||||
"content": `<p>从 Wiki URL 读取</p>`,
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--doc", "https://example.larksuite.com/wiki/wikcnScriptURL",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script with URL: %v", err)
|
||||
}
|
||||
if stub.CapturedBody == nil {
|
||||
t.Fatal("online parse did not call the document fetch API")
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data docsScriptParseResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if envelope.Data.Profile.BlockCount != 1 || blockCount(envelope.Data.Profile.Blocks, "p") != 1 {
|
||||
t.Fatalf("profile = %+v, want one paragraph", envelope.Data.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsContentAndDocTogether(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-input-conflict"))
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", `<p>local</p>`,
|
||||
"--doc", "doxcnScriptConflict",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--content", "--doc")
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsDocForMarkdownConversion(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-doc-convert"))
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--doc", "doxcnScriptConvert",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--doc")
|
||||
}
|
||||
|
||||
func TestDocsScriptConvertsMarkdownFromStdin(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-markdown"))
|
||||
f.IOStreams.In = bytes.NewBufferString("# 标题\n\n- item")
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "-",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `<h1>标题</h1><ul><li>item</li></ul>`) {
|
||||
t.Fatalf("stdout missing converted XML: %s", stdout)
|
||||
}
|
||||
var envelope struct {
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if len(envelope.Data) != 1 || envelope.Data["xml"] == nil {
|
||||
t.Fatalf("data = %+v, want only xml", envelope.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptConvertsMarkdownToOutputFile(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
withDocsWorkingDir(t, workDir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output"))
|
||||
wantXML := `<h1>标题</h1><ul><li>item</li></ul>`
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# 标题\n\n- item",
|
||||
"--output", "draft.xml",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script: %v", err)
|
||||
}
|
||||
gotXML, err := os.ReadFile("draft.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("read output XML: %v", err)
|
||||
}
|
||||
if string(gotXML) != wantXML {
|
||||
t.Fatalf("output XML = %q, want %q", gotXML, wantXML)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
SavedPath string `json:"saved_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
XML json.RawMessage `json:"xml"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if envelope.Data.SavedPath != filepath.Join(workDir, "draft.xml") {
|
||||
t.Fatalf("saved_path = %q, want %q", envelope.Data.SavedPath, filepath.Join(workDir, "draft.xml"))
|
||||
}
|
||||
if envelope.Data.SizeBytes != int64(len(wantXML)) {
|
||||
t.Fatalf("size_bytes = %d, want %d", envelope.Data.SizeBytes, len(wantXML))
|
||||
}
|
||||
if envelope.Data.XML != nil {
|
||||
t.Fatalf("data.xml should be omitted when --output is used: %s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptCreatesUniqueTempXMLFiles(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
withDocsWorkingDir(t, workDir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml"))
|
||||
|
||||
create := func() docsScriptTempXMLResult {
|
||||
t.Helper()
|
||||
stdout.Reset()
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptCreateTempXML,
|
||||
"--file-name", "川西",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script: %v", err)
|
||||
}
|
||||
var envelope struct {
|
||||
Data docsScriptTempXMLResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
first := create()
|
||||
second := create()
|
||||
if first.Path == second.Path {
|
||||
t.Fatalf("temporary paths are identical: %q", first.Path)
|
||||
}
|
||||
for _, got := range []docsScriptTempXMLResult{first, second} {
|
||||
directory := filepath.Dir(got.Path)
|
||||
if filepath.Base(got.Path) != "川西.xml" || filepath.Base(directory) != directory ||
|
||||
!strings.HasPrefix(directory, "川西_") || !strings.HasSuffix(directory, "_folder") {
|
||||
t.Fatalf("path = %q, want 川西_<random>_folder/川西.xml", got.Path)
|
||||
}
|
||||
info, err := os.Stat(got.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat temporary XML %q: %v", got.Path, err)
|
||||
}
|
||||
if info.Size() != 0 {
|
||||
t.Fatalf("temporary XML %q size = %d, want 0", got.Path, info.Size())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptCreateTempXMLRejectsOtherFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
param string
|
||||
}{
|
||||
{name: "content", args: []string{"--content", "<p>text</p>"}, param: "--content"},
|
||||
{name: "doc", args: []string{"--doc", "doxcnScriptTemp"}, param: "--doc"},
|
||||
{name: "output", args: []string{"--output", "draft.xml"}, param: "--output"},
|
||||
{name: "overwrite", args: []string{"--overwrite"}, param: "--overwrite"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml-flags"))
|
||||
args := []string{"+script", "--command", docsScriptCreateTempXML, "--file-name", "川西", "--as", "bot"}
|
||||
args = append(args, test.args...)
|
||||
err := mountAndRunDocs(t, DocsScript, args, f, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected %s validation error", test.param)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != test.param {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptCreateTempXMLValidatesFileName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileName string
|
||||
}{
|
||||
{name: "missing"},
|
||||
{name: "path", fileName: "folder/川西"},
|
||||
{name: "windows path", fileName: `folder\川西`},
|
||||
{name: "reserved character", fileName: "川西:一"},
|
||||
{name: "xml extension included", fileName: "川西.xml"},
|
||||
{name: "windows device", fileName: "CON"},
|
||||
{name: "surrounding whitespace", fileName: " 川西"},
|
||||
{name: "dangerous unicode", fileName: "川\u200b西"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml-file-name"))
|
||||
args := []string{"+script", "--command", docsScriptCreateTempXML, "--as", "bot"}
|
||||
if test.fileName != "" {
|
||||
args = append(args, "--file-name", test.fileName)
|
||||
}
|
||||
err := mountAndRunDocs(t, DocsScript, args, f, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected --file-name validation error for %q", test.fileName)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != "--file-name" {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptOutputRequiresExplicitOverwrite(t *testing.T) {
|
||||
withDocsWorkingDir(t, t.TempDir())
|
||||
if err := os.WriteFile("draft.xml", []byte("old"), 0o600); err != nil {
|
||||
t.Fatalf("write existing output: %v", err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-overwrite"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# new",
|
||||
"--output", "draft.xml",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected existing output error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != "--output" {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
got, readErr := os.ReadFile("draft.xml")
|
||||
if readErr != nil || string(got) != "old" {
|
||||
t.Fatalf("existing output changed: content=%q err=%v", got, readErr)
|
||||
}
|
||||
|
||||
err = mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# new",
|
||||
"--output", "draft.xml",
|
||||
"--overwrite",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script with --overwrite: %v", err)
|
||||
}
|
||||
got, readErr = os.ReadFile("draft.xml")
|
||||
if readErr != nil || string(got) != "<h1>new</h1>" {
|
||||
t.Fatalf("overwritten output = %q, err=%v", got, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsOutputForParse(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output-parse"))
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", `<p>text</p>`,
|
||||
"--output", "draft.xml",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected --output validation error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != "--output" {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsUnsafeOutputPath(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output-path"))
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# title",
|
||||
"--output", filepath.Join(t.TempDir(), "draft.xml"),
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output path error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
!errors.As(err, &validationErr) || validationErr.Param != "--output" {
|
||||
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptDryRunHasNoAPICall(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-dry-run"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", `<p>text</p>`,
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script dry-run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []any `json:"api"`
|
||||
Command string `json:"command"`
|
||||
Network bool `json:"network"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if len(got.API) != 0 || got.Command != docsScriptParse || got.Network {
|
||||
t.Fatalf("dry-run output = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptCreateTempXMLDryRunDoesNotWrite(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
withDocsWorkingDir(t, workDir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml-dry-run"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptCreateTempXML,
|
||||
"--file-name", "川西",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script dry-run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []any `json:"api"`
|
||||
Command string `json:"command"`
|
||||
DirectoryPattern string `json:"directory_pattern"`
|
||||
FileName string `json:"file_name"`
|
||||
XMLFileName string `json:"xml_file_name"`
|
||||
CreatesFile bool `json:"creates_file"`
|
||||
Network bool `json:"network"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if len(got.API) != 0 || got.Command != docsScriptCreateTempXML ||
|
||||
got.DirectoryPattern != "川西_*_folder" || got.FileName != "川西" || got.XMLFileName != "川西.xml" ||
|
||||
got.CreatesFile || got.Network {
|
||||
t.Fatalf("dry-run output = %+v", got)
|
||||
}
|
||||
entries, err := os.ReadDir(workDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read work directory: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("dry-run created files: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptOnlineDryRunShowsFetchAPICall(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-dry-run"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--doc", "https://example.larksuite.com/docx/doxcnScriptDryRun",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute online docs +script dry-run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Command string `json:"command"`
|
||||
DocumentID string `json:"document_id"`
|
||||
Network bool `json:"network"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if len(got.API) != 1 || got.API[0].Method != "POST" ||
|
||||
got.API[0].URL != "/open-apis/docs_ai/v1/documents/doxcnScriptDryRun/fetch" {
|
||||
t.Fatalf("dry-run API = %+v", got.API)
|
||||
}
|
||||
if got.API[0].Body["format"] != "xml" {
|
||||
t.Fatalf("dry-run body = %+v, want XML fetch", got.API[0].Body)
|
||||
}
|
||||
if got.Command != docsScriptParse || got.DocumentID != "doxcnScriptDryRun" || !got.Network {
|
||||
t.Fatalf("dry-run output = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptOutputDryRunDoesNotWrite(t *testing.T) {
|
||||
withDocsWorkingDir(t, t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output-dry-run"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptMarkdownToXML,
|
||||
"--content", "# title",
|
||||
"--output", "draft.xml",
|
||||
"--overwrite",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute docs +script dry-run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []any `json:"api"`
|
||||
Command string `json:"command"`
|
||||
Network bool `json:"network"`
|
||||
Output string `json:"output"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if len(got.API) != 0 || got.Command != docsScriptMarkdownToXML || got.Network || got.Output != "draft.xml" || !got.Overwrite {
|
||||
t.Fatalf("dry-run output = %+v", got)
|
||||
}
|
||||
if _, err := os.Stat("draft.xml"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("dry-run created output file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptReturnsTypedParseError(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-error"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", `<!DOCTYPE document><p>text</p>`,
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %+v, ok=%v", problem, ok)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--content" {
|
||||
t.Fatalf("error = %#v, want --content metadata", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptRejectsMalformedXML(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-malformed"))
|
||||
|
||||
err := mountAndRunDocs(t, DocsScript, []string{
|
||||
"+script",
|
||||
"--command", docsScriptParse,
|
||||
"--content", `<p>text`,
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected malformed XML error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %+v, ok=%v", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsScriptHelpExamplesAreCrossShellSafe(t *testing.T) {
|
||||
cmd := &cobra.Command{Short: "local document parser"}
|
||||
installDocsScriptHelp(cmd)
|
||||
if strings.Contains(cmd.Example, "cat ") {
|
||||
t.Fatalf("help examples require a platform-specific command: %q", cmd.Example)
|
||||
}
|
||||
if strings.Contains(cmd.Example, "--content @") {
|
||||
t.Fatalf("help examples contain an unquoted @file argument: %q", cmd.Example)
|
||||
}
|
||||
for _, want := range []string{`--command create-temp-xml --file-name "draft"`, `--content "@draft.xml"`, `--content "@draft.md"`, `--output "draft.xml"`} {
|
||||
if !strings.Contains(cmd.Example, want) {
|
||||
t.Errorf("help examples missing %q: %q", want, cmd.Example)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func blockCount(blocks []docxparse.BlockShare, typ string) int {
|
||||
for _, block := range blocks {
|
||||
if block.Type == typ {
|
||||
return block.Count
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
743
shortcuts/doc/internal/docxparse/markdown.go
Normal file
743
shortcuts/doc/internal/docxparse/markdown.go
Normal file
@@ -0,0 +1,743 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
// Markdown conversion is scoped to the docs +script business domain.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
gmutil "github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
var markdownParser parser.Parser
|
||||
|
||||
func init() {
|
||||
markdown := goldmark.New(
|
||||
goldmark.WithExtensions(
|
||||
extension.GFM,
|
||||
extension.DefinitionList,
|
||||
&mathExtension{},
|
||||
&underscoreHTMLExtension{},
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithBlockParsers(gmutil.Prioritized(&containerBlockParser{}, 90)),
|
||||
),
|
||||
)
|
||||
markdownParser = markdown.Parser()
|
||||
}
|
||||
|
||||
func parseMarkdown(source string) ([]*Node, error) {
|
||||
if err := validateSource(source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source = strings.TrimPrefix(source, "\uFEFF")
|
||||
source = normalizeListIndent(source)
|
||||
source = preprocessCJKAdjacentMarkup(source)
|
||||
data := []byte(source)
|
||||
document := markdownParser.Parse(text.NewReader(data))
|
||||
return renderBlockChildren(document, data)
|
||||
}
|
||||
|
||||
func renderBlockChildren(parent gast.Node, source []byte) ([]*Node, error) {
|
||||
var out []*Node
|
||||
for child := parent.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
nodes, err := renderBlockNode(child, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, nodes...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func renderBlockNode(node gast.Node, source []byte) ([]*Node, error) {
|
||||
switch node.Kind() {
|
||||
case gast.KindParagraph, gast.KindTextBlock:
|
||||
children, err := renderInlineChildren(node, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wrapParagraphChildren(children), nil
|
||||
case gast.KindHeading:
|
||||
heading := newElement(headingTag(node.(*gast.Heading).Level), nil)
|
||||
children, err := renderInlineChildren(node, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
heading.addChild(child)
|
||||
}
|
||||
return []*Node{heading}, nil
|
||||
case gast.KindBlockquote:
|
||||
return renderContainer("blockquote", nil, node, source)
|
||||
case gast.KindList:
|
||||
return renderList(node.(*gast.List), source)
|
||||
case gast.KindFencedCodeBlock:
|
||||
block := node.(*gast.FencedCodeBlock)
|
||||
language := string(block.Language(source))
|
||||
content := trimOneTrailingNewline(string(node.Lines().Value(source)))
|
||||
lowerLanguage := strings.ToLower(language)
|
||||
if content != "" && (lowerLanguage == "mermaid" || lowerLanguage == "plantuml" || lowerLanguage == "svg") {
|
||||
whiteboard := newElement("whiteboard", map[string]string{"type": lowerLanguage})
|
||||
appendRawTextWithBreaks(whiteboard, content)
|
||||
return []*Node{whiteboard}, nil
|
||||
}
|
||||
attrs := map[string]string(nil)
|
||||
if language != "" {
|
||||
attrs = map[string]string{"lang": language}
|
||||
}
|
||||
pre := newElement("pre", attrs)
|
||||
code := newElement("code", nil)
|
||||
appendRawTextWithBreaks(code, content)
|
||||
pre.addChild(code)
|
||||
return []*Node{pre}, nil
|
||||
case gast.KindCodeBlock:
|
||||
pre := newElement("pre", nil)
|
||||
code := newElement("code", nil)
|
||||
appendRawTextWithBreaks(code, trimOneTrailingNewline(string(node.Lines().Value(source))))
|
||||
pre.addChild(code)
|
||||
return []*Node{pre}, nil
|
||||
case gast.KindThematicBreak:
|
||||
return []*Node{newElement("hr", nil)}, nil
|
||||
case gast.KindHTMLBlock:
|
||||
nodes, err := parseMarkdownHTMLBlock(string(node.Lines().Value(source)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stripMarkdownEscapesInNodes(nodes, false, false)
|
||||
return nodes, nil
|
||||
case kindContainerBlock:
|
||||
container := node.(*containerBlock)
|
||||
return renderContainer(container.spec.tag, container.attrs, node, source)
|
||||
}
|
||||
|
||||
switch node.Kind() {
|
||||
case extast.KindTable:
|
||||
return renderTable(node, source)
|
||||
case extast.KindDefinitionList:
|
||||
return renderDefinitionList(node, source)
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(extractMarkdownText(node, source))
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
paragraph := newElement("p", nil)
|
||||
paragraph.addChild(newText(value))
|
||||
return []*Node{paragraph}, nil
|
||||
}
|
||||
|
||||
// parseMarkdownHTMLBlock handles the source-bearing LarkOpenCLI blocks whose
|
||||
// Markdown bodies are literal text, then delegates every other XML fragment to
|
||||
// the strict XML parser. Escaping literal code is part of Markdown conversion.
|
||||
func parseMarkdownHTMLBlock(fragment string) ([]*Node, error) {
|
||||
trimmed := strings.TrimSpace(fragment)
|
||||
for _, tag := range []string{"code", "whiteboard"} {
|
||||
closing := "</" + tag + ">"
|
||||
if !strings.HasPrefix(trimmed, "<"+tag) || !strings.HasSuffix(trimmed, closing) {
|
||||
continue
|
||||
}
|
||||
token, contentStart, state := scanXMLToken(trimmed, 0)
|
||||
if state != tokenOK || token.closing || token.selfClosing || token.name != tag {
|
||||
return nil, fmt.Errorf("invalid Markdown <%s> block", tag)
|
||||
}
|
||||
contentEnd := len(trimmed) - len(closing)
|
||||
if contentStart > contentEnd {
|
||||
return nil, fmt.Errorf("invalid Markdown <%s> block", tag)
|
||||
}
|
||||
attrs := normalizeAttributes(tag, tag, token.attrs)
|
||||
block := newElement(tag, attrs)
|
||||
appendRawTextWithBreaks(block, strings.Trim(trimmed[contentStart:contentEnd], "\r\n"))
|
||||
return []*Node{block}, nil
|
||||
}
|
||||
return parseXML(fragment)
|
||||
}
|
||||
|
||||
func renderContainer(tag string, attrs map[string]string, node gast.Node, source []byte) ([]*Node, error) {
|
||||
attrs = normalizeAttributes(tag, tag, attrs)
|
||||
container := newElement(tag, attrs)
|
||||
children, err := renderBlockChildren(node, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
container.addChild(child)
|
||||
}
|
||||
return []*Node{container}, nil
|
||||
}
|
||||
|
||||
func renderList(list *gast.List, source []byte) ([]*Node, error) {
|
||||
if isTaskList(list) {
|
||||
return renderTaskList(list, source)
|
||||
}
|
||||
tag := "ul"
|
||||
if list.IsOrdered() {
|
||||
tag = "ol"
|
||||
}
|
||||
listNode := newElement(tag, nil)
|
||||
for child := list.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() != gast.KindListItem {
|
||||
continue
|
||||
}
|
||||
item, err := renderListItem(child.(*gast.ListItem), list.IsTight, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listNode.addChild(item)
|
||||
}
|
||||
return []*Node{listNode}, nil
|
||||
}
|
||||
|
||||
func isTaskList(list *gast.List) bool {
|
||||
first := list.FirstChild()
|
||||
if first == nil || first.Kind() != gast.KindListItem {
|
||||
return false
|
||||
}
|
||||
return findTaskCheckbox(first.(*gast.ListItem)) != nil
|
||||
}
|
||||
|
||||
func findTaskCheckbox(item *gast.ListItem) *extast.TaskCheckBox {
|
||||
for child := item.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() != gast.KindTextBlock && child.Kind() != gast.KindParagraph {
|
||||
continue
|
||||
}
|
||||
if first := child.FirstChild(); first != nil && first.Kind() == extast.KindTaskCheckBox {
|
||||
return first.(*extast.TaskCheckBox)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderTaskList(list *gast.List, source []byte) ([]*Node, error) {
|
||||
var out []*Node
|
||||
for child := list.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() != gast.KindListItem {
|
||||
continue
|
||||
}
|
||||
item := child.(*gast.ListItem)
|
||||
checkboxAST := findTaskCheckbox(item)
|
||||
if checkboxAST == nil {
|
||||
li, err := renderListItem(item, list.IsTight, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ul := newElement("ul", nil)
|
||||
ul.addChild(li)
|
||||
out = append(out, ul)
|
||||
continue
|
||||
}
|
||||
done := "false"
|
||||
if checkboxAST.IsChecked {
|
||||
done = "true"
|
||||
}
|
||||
checkbox := newElement("checkbox", map[string]string{"done": done})
|
||||
for block := item.FirstChild(); block != nil; block = block.NextSibling() {
|
||||
if block.Kind() == gast.KindTextBlock || block.Kind() == gast.KindParagraph {
|
||||
fragment, err := renderInlineFragment(block, source, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := parseMarkdownInlineFragment(fragment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
checkbox.addChild(node)
|
||||
}
|
||||
continue
|
||||
}
|
||||
nodes, err := renderBlockNode(block, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
checkbox.addChild(node)
|
||||
}
|
||||
}
|
||||
out = append(out, checkbox)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func renderListItem(item *gast.ListItem, tight bool, source []byte) (*Node, error) {
|
||||
li := newElement("li", nil)
|
||||
children, err := renderBlockChildren(item, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
if child.tag == "p" && (tight || paragraphOnlyInline(child)) {
|
||||
for _, grandchild := range child.children {
|
||||
li.addChild(grandchild)
|
||||
}
|
||||
continue
|
||||
}
|
||||
li.addChild(child)
|
||||
}
|
||||
return li, nil
|
||||
}
|
||||
|
||||
func renderInlineChildren(node gast.Node, source []byte) ([]*Node, error) {
|
||||
fragment, err := renderInlineFragment(node, source, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := parseMarkdownInlineFragment(fragment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stripMarkdownEscapesInNodes(nodes, false, false)
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// parseMarkdownInlineFragment wraps an inline fragment in a space-preserving
|
||||
// parent while parsing so XML normalization keeps semantic spaces between
|
||||
// adjacent inline elements. The wrapper is removed from the returned nodes.
|
||||
func parseMarkdownInlineFragment(fragment string) ([]*Node, error) {
|
||||
nodes, err := parseXML("<p>" + fragment + "</p>")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(nodes) != 1 || nodes[0].typ != nodeElement || nodes[0].tag != "p" {
|
||||
return nil, fmt.Errorf("invalid Markdown inline fragment")
|
||||
}
|
||||
children := nodes[0].children
|
||||
for _, child := range children {
|
||||
child.parent = nil
|
||||
}
|
||||
return children, nil
|
||||
}
|
||||
|
||||
func renderInlineFragment(parent gast.Node, source []byte, skipCheckbox bool) (string, error) {
|
||||
var out strings.Builder
|
||||
for child := parent.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if skipCheckbox && child.Kind() == extast.KindTaskCheckBox {
|
||||
continue
|
||||
}
|
||||
fragment, err := renderInlineNode(child, source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out.WriteString(fragment)
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
func renderInlineNode(node gast.Node, source []byte) (string, error) {
|
||||
switch node.Kind() {
|
||||
case gast.KindText:
|
||||
textNode := node.(*gast.Text)
|
||||
value := escapeXMLText(stripBackslashEscapes(string(textNode.Value(source))))
|
||||
switch {
|
||||
case textNode.HardLineBreak():
|
||||
value += "<br/>"
|
||||
case textNode.SoftLineBreak():
|
||||
value += " "
|
||||
}
|
||||
return value, nil
|
||||
case gast.KindString:
|
||||
return escapeXMLText(string(node.(*gast.String).Value)), nil
|
||||
case gast.KindEmphasis:
|
||||
tag := "em"
|
||||
if node.(*gast.Emphasis).Level >= 2 {
|
||||
tag = "b"
|
||||
}
|
||||
return renderInlineContainer(node, tag, nil, source)
|
||||
case gast.KindCodeSpan:
|
||||
return elementXML("code", nil, escapeXMLText(collectMarkdownChildText(node, source))), nil
|
||||
case gast.KindLink:
|
||||
link := node.(*gast.Link)
|
||||
attrs := map[string]string{"href": string(link.Destination)}
|
||||
if len(link.Title) > 0 {
|
||||
attrs["title"] = string(link.Title)
|
||||
}
|
||||
children, err := renderInlineFragment(node, source, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if children == "" {
|
||||
children = escapeXMLText(string(link.Destination))
|
||||
}
|
||||
return elementXML("a", attrs, children), nil
|
||||
case gast.KindImage:
|
||||
image := node.(*gast.Image)
|
||||
destination := string(image.Destination)
|
||||
attrs := map[string]string{}
|
||||
if strings.HasPrefix(destination, "http://") || strings.HasPrefix(destination, "https://") {
|
||||
attrs["href"] = destination
|
||||
} else {
|
||||
attrs["src"] = destination
|
||||
}
|
||||
if len(image.Title) > 0 {
|
||||
attrs["title"] = string(image.Title)
|
||||
}
|
||||
return elementXML("img", attrs, ""), nil
|
||||
case gast.KindRawHTML:
|
||||
return string(node.(*gast.RawHTML).Segments.Value(source)), nil
|
||||
case gast.KindAutoLink:
|
||||
link := node.(*gast.AutoLink)
|
||||
return elementXML("a", map[string]string{"href": string(link.URL(source))}, escapeXMLText(string(link.Label(source)))), nil
|
||||
}
|
||||
|
||||
switch node.Kind() {
|
||||
case extast.KindStrikethrough:
|
||||
return renderInlineContainer(node, "del", nil, source)
|
||||
case kindMathInline:
|
||||
return elementXML("latex", nil, escapeXMLText(stripLatexMarkdownEscapes(string(node.(*mathInline).content)))), nil
|
||||
case kindMathBlock:
|
||||
return elementXML("latex", nil, escapeXMLText(stripLatexMarkdownEscapes(string(node.(*mathBlock).content)))), nil
|
||||
case extast.KindTaskCheckBox:
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if node.Type() == gast.TypeBlock {
|
||||
return escapeXMLText(strings.TrimSpace(extractMarkdownText(node, source))), nil
|
||||
}
|
||||
return escapeXMLText(extractMarkdownText(node, source)), nil
|
||||
}
|
||||
|
||||
func renderInlineContainer(node gast.Node, tag string, attrs map[string]string, source []byte) (string, error) {
|
||||
children, err := renderInlineFragment(node, source, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return elementXML(tag, attrs, children), nil
|
||||
}
|
||||
|
||||
func elementXML(tag string, attrs map[string]string, inner string) string {
|
||||
node := newElement(tag, attrs)
|
||||
rendered := renderNodes([]*Node{node})
|
||||
if inner == "" {
|
||||
return rendered
|
||||
}
|
||||
close := "</" + tag + ">"
|
||||
if strings.HasSuffix(rendered, close) {
|
||||
return strings.TrimSuffix(rendered, close) + inner + close
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
func wrapParagraphChildren(children []*Node) []*Node {
|
||||
var out []*Node
|
||||
var inline []*Node
|
||||
flush := func() {
|
||||
if len(inline) == 0 {
|
||||
return
|
||||
}
|
||||
paragraph := newElement("p", nil)
|
||||
for _, child := range inline {
|
||||
paragraph.addChild(child)
|
||||
}
|
||||
out = append(out, paragraph)
|
||||
inline = nil
|
||||
}
|
||||
for _, child := range children {
|
||||
if child != nil && child.typ == nodeElement && layoutOf(child.tag) == layoutBlock {
|
||||
flush()
|
||||
out = append(out, child)
|
||||
continue
|
||||
}
|
||||
inline = append(inline, child)
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
func paragraphOnlyInline(node *Node) bool {
|
||||
if node == nil || node.typ != nodeElement || node.tag != "p" {
|
||||
return false
|
||||
}
|
||||
for _, child := range node.children {
|
||||
if child.typ == nodeElement && layoutOf(child.tag) == layoutBlock {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func renderTable(node gast.Node, source []byte) ([]*Node, error) {
|
||||
table := newElement("table", nil)
|
||||
var body *Node
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
switch child.Kind() {
|
||||
case extast.KindTableHeader:
|
||||
head := newElement("thead", nil)
|
||||
row, err := renderTableRow(child, true, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
head.addChild(row)
|
||||
table.addChild(head)
|
||||
case extast.KindTableRow:
|
||||
if body == nil {
|
||||
body = newElement("tbody", nil)
|
||||
table.addChild(body)
|
||||
}
|
||||
row, err := renderTableRow(child, false, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body.addChild(row)
|
||||
}
|
||||
}
|
||||
return []*Node{table}, nil
|
||||
}
|
||||
|
||||
func renderTableRow(node gast.Node, header bool, source []byte) (*Node, error) {
|
||||
row := newElement("tr", nil)
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() != extast.KindTableCell {
|
||||
continue
|
||||
}
|
||||
cellAST := child.(*extast.TableCell)
|
||||
tag := "td"
|
||||
if header {
|
||||
tag = "th"
|
||||
}
|
||||
attrs := map[string]string(nil)
|
||||
switch cellAST.Alignment {
|
||||
case extast.AlignCenter:
|
||||
attrs = map[string]string{"align": "center"}
|
||||
case extast.AlignRight:
|
||||
attrs = map[string]string{"align": "right"}
|
||||
}
|
||||
cell := newElement(tag, attrs)
|
||||
content, err := renderInlineChildren(cellAST, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, inline := range content {
|
||||
cell.addChild(inline)
|
||||
}
|
||||
row.addChild(cell)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func renderDefinitionList(node gast.Node, source []byte) ([]*Node, error) {
|
||||
var out []*Node
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
switch child.Kind() {
|
||||
case extast.KindDefinitionTerm:
|
||||
fragment, err := renderInlineFragment(child, source, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := parseXML(fragment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paragraph := newElement("p", nil)
|
||||
bold := newElement("b", nil)
|
||||
for _, node := range nodes {
|
||||
bold.addChild(node)
|
||||
}
|
||||
paragraph.addChild(bold)
|
||||
out = append(out, paragraph)
|
||||
case extast.KindDefinitionDescription:
|
||||
quote, err := renderContainer("blockquote", nil, child, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, quote...)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func appendRawTextWithBreaks(parent *Node, content string) {
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
start := 0
|
||||
for i := 0; i < len(content); i++ {
|
||||
if content[i] != '\n' && content[i] != '\r' {
|
||||
continue
|
||||
}
|
||||
if i > start {
|
||||
parent.addChild(newText(content[start:i]))
|
||||
}
|
||||
if content[i] == '\r' && i+1 < len(content) && content[i+1] == '\n' {
|
||||
i++
|
||||
}
|
||||
parent.addChild(newElement("br", nil))
|
||||
start = i + 1
|
||||
}
|
||||
if start < len(content) {
|
||||
parent.addChild(newText(content[start:]))
|
||||
}
|
||||
}
|
||||
|
||||
func stripMarkdownEscapesInNodes(nodes []*Node, inCode, inLatex bool) {
|
||||
for _, node := range nodes {
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
if node.typ == nodeText {
|
||||
switch {
|
||||
case inCode:
|
||||
case inLatex:
|
||||
node.text = stripLatexMarkdownEscapes(node.text)
|
||||
default:
|
||||
node.text = stripBackslashEscapes(node.text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
stripMarkdownEscapesInNodes(node.children, inCode || node.tag == "code" || node.tag == "pre", inLatex || node.tag == "latex")
|
||||
}
|
||||
}
|
||||
|
||||
func stripBackslashEscapes(value string) string {
|
||||
if !strings.Contains(value, `\`) {
|
||||
return value
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(value))
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] == '\\' && i+1 < len(value) && isASCIIPunctuation(value[i+1]) {
|
||||
out.WriteByte(value[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out.WriteByte(value[i])
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func stripLatexMarkdownEscapes(value string) string {
|
||||
if !strings.Contains(value, `\`) {
|
||||
return value
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(value))
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] == '\\' && i+1 < len(value) && strings.ContainsRune("_^&*[]$~<>`#+-=:", rune(value[i+1])) {
|
||||
out.WriteByte(value[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out.WriteByte(value[i])
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func isASCIIPunctuation(ch byte) bool {
|
||||
return ch >= '!' && ch <= '/' || ch >= ':' && ch <= '@' || ch >= '[' && ch <= '`' || ch >= '{' && ch <= '~'
|
||||
}
|
||||
|
||||
func trimOneTrailingNewline(value string) string {
|
||||
if strings.HasSuffix(value, "\r\n") {
|
||||
return value[:len(value)-2]
|
||||
}
|
||||
return strings.TrimSuffix(value, "\n")
|
||||
}
|
||||
|
||||
func collectMarkdownChildText(node gast.Node, source []byte) string {
|
||||
var out strings.Builder
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
switch child.Kind() {
|
||||
case gast.KindText:
|
||||
out.Write(child.(*gast.Text).Value(source))
|
||||
case gast.KindString:
|
||||
out.Write(child.(*gast.String).Value)
|
||||
default:
|
||||
out.WriteString(collectMarkdownChildText(child, source))
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func extractMarkdownText(node gast.Node, source []byte) string {
|
||||
switch node.Kind() {
|
||||
case gast.KindText:
|
||||
return string(node.(*gast.Text).Value(source))
|
||||
case gast.KindString:
|
||||
return string(node.(*gast.String).Value)
|
||||
case gast.KindCodeSpan:
|
||||
return collectMarkdownChildText(node, source)
|
||||
}
|
||||
if node.Type() == gast.TypeBlock && node.Lines() != nil && node.Lines().Len() > 0 {
|
||||
return string(node.Lines().Value(source))
|
||||
}
|
||||
var out strings.Builder
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
out.WriteString(extractMarkdownText(child, source))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func headingTag(level int) string {
|
||||
if level < 1 || level > 6 {
|
||||
return "p"
|
||||
}
|
||||
return fmt.Sprintf("h%d", level)
|
||||
}
|
||||
|
||||
func normalizeListIndent(markdown string) string {
|
||||
lines := strings.Split(markdown, "\n")
|
||||
type stackEntry struct{ indent int }
|
||||
var stack []stackEntry
|
||||
inFence := false
|
||||
changed := false
|
||||
lastOriginal, lastNormalized := 0, 0
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimLeft(line, " ")
|
||||
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
||||
inFence = !inFence
|
||||
continue
|
||||
}
|
||||
if inFence || trimmed == "" {
|
||||
continue
|
||||
}
|
||||
indent := len(line) - len(trimmed)
|
||||
if markdownListMarkerLength(trimmed) > 0 {
|
||||
for len(stack) > 0 && indent <= stack[len(stack)-1].indent {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
normalized := len(stack) * 4
|
||||
stack = append(stack, stackEntry{indent: indent})
|
||||
lastOriginal, lastNormalized = indent, normalized
|
||||
if indent != normalized {
|
||||
lines[i] = strings.Repeat(" ", normalized) + trimmed
|
||||
changed = true
|
||||
}
|
||||
} else if len(stack) > 0 && indent > lastOriginal {
|
||||
delta := lastNormalized - lastOriginal
|
||||
if delta != 0 {
|
||||
normalized := indent + delta
|
||||
if normalized < 0 {
|
||||
normalized = 0
|
||||
}
|
||||
lines[i] = strings.Repeat(" ", normalized) + trimmed
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return markdown
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func markdownListMarkerLength(value string) int {
|
||||
if len(value) >= 2 && (value[0] == '-' || value[0] == '*' || value[0] == '+') && value[1] == ' ' {
|
||||
return 2
|
||||
}
|
||||
i := 0
|
||||
for i < len(value) && value[i] >= '0' && value[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i > 0 && i+1 < len(value) && (value[i] == '.' || value[i] == ')') && value[i+1] == ' ' {
|
||||
return i + 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
284
shortcuts/doc/internal/docxparse/markdown_cjk.go
Normal file
284
shortcuts/doc/internal/docxparse/markdown_cjk.go
Normal file
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// preprocessCJKAdjacentMarkup disambiguates a narrow CommonMark pattern common in
|
||||
// Chinese prose: emphasis that ends in punctuation and is immediately followed
|
||||
// by a letter (for example **结论。**下一步). Goldmark correctly follows
|
||||
// CommonMark's delimiter rules, while LarkOpenCLI accepts this authoring form.
|
||||
// Rewriting simple CJK delimiter spans to equivalent DocxXML
|
||||
// before parsing removes the ambiguity while leaving nested Markdown, links,
|
||||
// code, fenced blocks, and source-bearing XML untouched.
|
||||
func preprocessCJKAdjacentMarkup(markdown string) string {
|
||||
if !strings.Contains(markdown, "**") && !strings.Contains(markdown, "~~") {
|
||||
return markdown
|
||||
}
|
||||
lines := strings.SplitAfter(markdown, "\n")
|
||||
var out strings.Builder
|
||||
fenceMarker := rune(0)
|
||||
fenceLength := 0
|
||||
rawSourceTag := ""
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimLeft(line, " \t>")
|
||||
if marker, length, ok := markdownFence(trimmed); ok {
|
||||
if fenceMarker == 0 {
|
||||
fenceMarker, fenceLength = marker, length
|
||||
} else if marker == fenceMarker && length >= fenceLength && strings.TrimSpace(runeTail(trimmed, length)) == "" {
|
||||
fenceMarker, fenceLength = 0, 0
|
||||
}
|
||||
out.WriteString(line)
|
||||
continue
|
||||
}
|
||||
if fenceMarker != 0 || leadingIndent(line) >= 4 {
|
||||
out.WriteString(line)
|
||||
continue
|
||||
}
|
||||
out.WriteString(rewriteCJKMarkupLine(line, &rawSourceTag))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func markdownFence(line string) (rune, int, bool) {
|
||||
runes := []rune(line)
|
||||
if len(runes) < 3 || runes[0] != '`' && runes[0] != '~' {
|
||||
return 0, 0, false
|
||||
}
|
||||
marker := runes[0]
|
||||
length := 0
|
||||
for length < len(runes) && runes[length] == marker {
|
||||
length++
|
||||
}
|
||||
return marker, length, length >= 3
|
||||
}
|
||||
|
||||
func runeTail(value string, start int) string {
|
||||
runes := []rune(value)
|
||||
if start >= len(runes) {
|
||||
return ""
|
||||
}
|
||||
return string(runes[start:])
|
||||
}
|
||||
|
||||
func leadingIndent(line string) int {
|
||||
count := 0
|
||||
for _, r := range line {
|
||||
switch r {
|
||||
case ' ':
|
||||
count++
|
||||
case '\t':
|
||||
count += 4
|
||||
default:
|
||||
return count
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
type cjkMarkupRule struct {
|
||||
delimiter []rune
|
||||
openXML string
|
||||
closeXML string
|
||||
}
|
||||
|
||||
var cjkMarkupRules = []cjkMarkupRule{
|
||||
{delimiter: []rune("***"), openXML: "<em><b>", closeXML: "</b></em>"},
|
||||
{delimiter: []rune("~~"), openXML: "<del>", closeXML: "</del>"},
|
||||
{delimiter: []rune("**"), openXML: "<b>", closeXML: "</b>"},
|
||||
}
|
||||
|
||||
func rewriteCJKMarkupLine(line string, rawSourceTag *string) string {
|
||||
if *rawSourceTag != "" {
|
||||
runes := []rune(line)
|
||||
closeTag := []rune("</" + *rawSourceTag + ">")
|
||||
closeAt := indexRunesFold(runes, 0, closeTag)
|
||||
if closeAt < 0 {
|
||||
return line
|
||||
}
|
||||
closeEnd := closeAt + len(closeTag)
|
||||
prefix := string(runes[:closeEnd])
|
||||
*rawSourceTag = ""
|
||||
return prefix + rewriteCJKMarkupLine(string(runes[closeEnd:]), rawSourceTag)
|
||||
}
|
||||
|
||||
runes := []rune(line)
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(runes); {
|
||||
if runes[i] == '`' && !runeEscaped(runes, i) {
|
||||
if end := codeSpanEnd(runes, i); end > i {
|
||||
out.WriteString(string(runes[i:end]))
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
}
|
||||
if runes[i] == '<' {
|
||||
if tag, end, selfClosing, ok := rawTagAt(runes, i); ok {
|
||||
out.WriteString(string(runes[i:end]))
|
||||
i = end
|
||||
if !selfClosing && (tag == "code" || tag == "pre" || tag == "whiteboard") {
|
||||
close := []rune("</" + tag + ">")
|
||||
if closeAt := indexRunesFold(runes, i, close); closeAt >= 0 {
|
||||
closeEnd := closeAt + len(close)
|
||||
out.WriteString(string(runes[i:closeEnd]))
|
||||
i = closeEnd
|
||||
} else {
|
||||
out.WriteString(string(runes[i:]))
|
||||
*rawSourceTag = tag
|
||||
return out.String()
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
rewritten := false
|
||||
for _, rule := range cjkMarkupRules {
|
||||
if !exactDelimiterAt(runes, i, rule.delimiter) || runeEscaped(runes, i) {
|
||||
continue
|
||||
}
|
||||
closeAt := delimiterCloser(runes, i+len(rule.delimiter), rule.delimiter)
|
||||
if closeAt < 0 {
|
||||
continue
|
||||
}
|
||||
content := runes[i+len(rule.delimiter) : closeAt]
|
||||
if !shouldRewriteCJKMarkup(content) {
|
||||
continue
|
||||
}
|
||||
out.WriteString(rule.openXML)
|
||||
out.WriteString(escapeXMLText(stripBackslashEscapes(string(content))))
|
||||
out.WriteString(rule.closeXML)
|
||||
i = closeAt + len(rule.delimiter)
|
||||
rewritten = true
|
||||
break
|
||||
}
|
||||
if rewritten {
|
||||
continue
|
||||
}
|
||||
out.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func rawTagAt(runes []rune, start int) (tag string, end int, selfClosing, ok bool) {
|
||||
if start+1 >= len(runes) || !isASCIILetterRune(runes[start+1]) {
|
||||
return "", 0, false, false
|
||||
}
|
||||
i := start + 1
|
||||
for i < len(runes) && (isASCIILetterRune(runes[i]) || isASCIIDigitRune(runes[i]) || runes[i] == '-' || runes[i] == '_') {
|
||||
i++
|
||||
}
|
||||
tag = strings.ToLower(string(runes[start+1 : i]))
|
||||
quote := rune(0)
|
||||
for ; i < len(runes); i++ {
|
||||
if runes[i] == '\'' || runes[i] == '"' {
|
||||
if quote == 0 {
|
||||
quote = runes[i]
|
||||
} else if quote == runes[i] {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if runes[i] == '>' && quote == 0 {
|
||||
trimmed := strings.TrimSpace(string(runes[start : i+1]))
|
||||
return tag, i + 1, strings.HasSuffix(trimmed, "/>"), true
|
||||
}
|
||||
}
|
||||
return "", 0, false, false
|
||||
}
|
||||
|
||||
func indexRunesFold(haystack []rune, start int, needle []rune) int {
|
||||
for i := start; i+len(needle) <= len(haystack); i++ {
|
||||
if strings.EqualFold(string(haystack[i:i+len(needle)]), string(needle)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func codeSpanEnd(runes []rune, open int) int {
|
||||
length := 0
|
||||
for open+length < len(runes) && runes[open+length] == '`' {
|
||||
length++
|
||||
}
|
||||
for i := open + length; i < len(runes); i++ {
|
||||
if runes[i] != '`' || runeEscaped(runes, i) {
|
||||
continue
|
||||
}
|
||||
end := i
|
||||
for end < len(runes) && runes[end] == '`' {
|
||||
end++
|
||||
}
|
||||
if end-i == length {
|
||||
return end
|
||||
}
|
||||
i = end - 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func exactDelimiterAt(runes []rune, start int, delimiter []rune) bool {
|
||||
if start+len(delimiter) > len(runes) {
|
||||
return false
|
||||
}
|
||||
for i, want := range delimiter {
|
||||
if runes[start+i] != want {
|
||||
return false
|
||||
}
|
||||
}
|
||||
marker := delimiter[0]
|
||||
return (start == 0 || runes[start-1] != marker) && (start+len(delimiter) == len(runes) || runes[start+len(delimiter)] != marker)
|
||||
}
|
||||
|
||||
func delimiterCloser(runes []rune, start int, delimiter []rune) int {
|
||||
for i := start; i+len(delimiter) <= len(runes); i++ {
|
||||
if runes[i] == '\n' {
|
||||
return -1
|
||||
}
|
||||
if exactDelimiterAt(runes, i, delimiter) && !runeEscaped(runes, i) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func shouldRewriteCJKMarkup(content []rune) bool {
|
||||
if len(content) == 0 || unicode.IsSpace(content[0]) || unicode.IsSpace(content[len(content)-1]) {
|
||||
return false
|
||||
}
|
||||
for _, r := range content {
|
||||
if r == '`' || r == '[' || r == ']' || r == '<' || r == '>' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !containsCJK(content) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsCJK(value []rune) bool {
|
||||
for _, r := range value {
|
||||
if isCJKRune(r) || r > unicode.MaxASCII && (unicode.IsPunct(r) || unicode.IsSymbol(r)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isCJKRune(r rune) bool {
|
||||
return unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul)
|
||||
}
|
||||
|
||||
func runeEscaped(runes []rune, index int) bool {
|
||||
count := 0
|
||||
for i := index - 1; i >= 0 && runes[i] == '\\'; i-- {
|
||||
count++
|
||||
}
|
||||
return count%2 == 1
|
||||
}
|
||||
334
shortcuts/doc/internal/docxparse/markdown_extensions.go
Normal file
334
shortcuts/doc/internal/docxparse/markdown_extensions.go
Normal file
@@ -0,0 +1,334 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
// This file contains the small Goldmark extensions needed to match the
|
||||
// LarkOpenCLI's Markdown surface: math, DocxXML tag names containing
|
||||
// underscores, and Markdown-aware callout/grid/column containers.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
gmutil "github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// ---------- Math ----------
|
||||
|
||||
var kindMathInline = gast.NewNodeKind("DocxMathInline")
|
||||
var kindMathBlock = gast.NewNodeKind("DocxMathBlock")
|
||||
|
||||
type mathInline struct {
|
||||
gast.BaseInline
|
||||
content []byte
|
||||
}
|
||||
|
||||
func (n *mathInline) Kind() gast.NodeKind { return kindMathInline }
|
||||
func (n *mathInline) Dump(source []byte, level int) {
|
||||
gast.DumpHelper(n, source, level, nil, nil)
|
||||
}
|
||||
|
||||
type mathBlock struct {
|
||||
gast.BaseInline
|
||||
content []byte
|
||||
}
|
||||
|
||||
func (n *mathBlock) Kind() gast.NodeKind { return kindMathBlock }
|
||||
func (n *mathBlock) Dump(source []byte, level int) {
|
||||
gast.DumpHelper(n, source, level, nil, nil)
|
||||
}
|
||||
|
||||
var (
|
||||
mathBlockMultiLine = regexp.MustCompile(`(?s)^\$\$(.+?)\$\$`)
|
||||
mathInlineMultiLine = regexp.MustCompile(`(?s)^\$([^ \t$].*?)\$`)
|
||||
)
|
||||
|
||||
type mathInlineParser struct{}
|
||||
|
||||
func (p *mathInlineParser) Trigger() []byte { return []byte{'$'} }
|
||||
|
||||
func (p *mathInlineParser) Parse(_ gast.Node, reader text.Reader, _ parser.Context) gast.Node {
|
||||
line, _ := reader.PeekLine()
|
||||
if len(line) == 0 || line[0] != '$' {
|
||||
return nil
|
||||
}
|
||||
if len(line) >= 2 && line[1] == '$' {
|
||||
if content, advance := scanMathClose(line[2:], "$$"); advance >= 0 && len(content) > 0 {
|
||||
reader.Advance(2 + advance)
|
||||
return &mathBlock{content: append([]byte(nil), content...)}
|
||||
}
|
||||
match := reader.FindSubMatch(mathBlockMultiLine)
|
||||
if len(match) >= 2 && len(bytes.TrimSpace(match[1])) > 0 && !bytes.Contains(match[1], []byte("<latex")) {
|
||||
return &mathBlock{content: append([]byte(nil), bytes.TrimSpace(match[1])...)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(line) < 2 || line[1] == ' ' || line[1] == '\t' || line[1] == '$' {
|
||||
return nil
|
||||
}
|
||||
if content, advance := scanMathClose(line[1:], "$"); advance >= 0 && len(content) > 0 {
|
||||
if content[len(content)-1] == ' ' || content[len(content)-1] == '\t' {
|
||||
return nil
|
||||
}
|
||||
reader.Advance(1 + advance)
|
||||
return &mathInline{content: append([]byte(nil), content...)}
|
||||
}
|
||||
match := reader.FindSubMatch(mathInlineMultiLine)
|
||||
if len(match) < 2 || bytes.Contains(match[1], []byte("<latex")) {
|
||||
return nil
|
||||
}
|
||||
trimmed := bytes.TrimRight(match[1], "\n\r")
|
||||
if len(trimmed) == 0 || trimmed[len(trimmed)-1] == ' ' || trimmed[len(trimmed)-1] == '\t' {
|
||||
return nil
|
||||
}
|
||||
return &mathInline{content: append([]byte(nil), trimmed...)}
|
||||
}
|
||||
|
||||
func scanMathClose(data []byte, delimiter string) ([]byte, int) {
|
||||
delim := []byte(delimiter)
|
||||
for offset := 0; offset < len(data); {
|
||||
if data[offset] == '\\' && offset+1 < len(data) && data[offset+1] == '$' {
|
||||
offset += 2
|
||||
continue
|
||||
}
|
||||
rel := bytes.Index(data[offset:], delim)
|
||||
if rel < 0 {
|
||||
return nil, -1
|
||||
}
|
||||
end := offset + rel
|
||||
if bytes.Contains(data[:end], []byte("<latex")) {
|
||||
return nil, -1
|
||||
}
|
||||
return data[:end], end + len(delim)
|
||||
}
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
type mathExtension struct{}
|
||||
|
||||
func (e *mathExtension) Extend(markdown goldmark.Markdown) {
|
||||
markdown.Parser().AddOptions(parser.WithInlineParsers(
|
||||
gmutil.Prioritized(&mathInlineParser{}, 100),
|
||||
))
|
||||
}
|
||||
|
||||
// ---------- Underscore-bearing raw XML tags ----------
|
||||
|
||||
type underscoreHTMLExtension struct{}
|
||||
|
||||
func (e *underscoreHTMLExtension) Extend(markdown goldmark.Markdown) {
|
||||
markdown.Parser().AddOptions(
|
||||
parser.WithInlineParsers(gmutil.Prioritized(&underscoreRawHTMLParser{}, 99)),
|
||||
parser.WithBlockParsers(gmutil.Prioritized(&underscoreHTMLBlockParser{}, 99)),
|
||||
)
|
||||
}
|
||||
|
||||
var (
|
||||
extendedTagNamePattern = `([A-Za-z][A-Za-z0-9_-]*)`
|
||||
extendedAttributePattern = `(?:\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\s*=\s*(?:[^"'=<>` + "`" + `\x00-\x20]+|'[^']*'|"[^"]*"))?)`
|
||||
extendedOpenTag = regexp.MustCompile("^<" + extendedTagNamePattern + extendedAttributePattern + `*\s*/?>`)
|
||||
extendedCloseTag = regexp.MustCompile("^</" + extendedTagNamePattern + `\s*>`)
|
||||
peekExtendedOpenTag = regexp.MustCompile(`^<([A-Za-z][A-Za-z0-9_-]*)`)
|
||||
peekExtendedCloseTag = regexp.MustCompile(`^</([A-Za-z][A-Za-z0-9_-]*)`)
|
||||
extendedBlockTag = regexp.MustCompile(`^[ ]{0,3}<(/)?\s*([a-zA-Z0-9_\-]+)(` + extendedAttributePattern + `*)\s*(?:>|/>)\s*\n?$`)
|
||||
)
|
||||
|
||||
type underscoreRawHTMLParser struct{}
|
||||
|
||||
func (p *underscoreRawHTMLParser) Trigger() []byte { return []byte{'<'} }
|
||||
|
||||
func (p *underscoreRawHTMLParser) Parse(_ gast.Node, reader text.Reader, _ parser.Context) gast.Node {
|
||||
line, _ := reader.PeekLine()
|
||||
if len(line) > 1 && gmutil.IsAlphaNumeric(line[1]) {
|
||||
if match := peekExtendedOpenTag.FindSubmatch(line); match != nil && bytes.IndexByte(match[1], '_') >= 0 {
|
||||
return p.parseMultiLine(extendedOpenTag, reader)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(line) > 2 && line[1] == '/' && gmutil.IsAlphaNumeric(line[2]) {
|
||||
if match := peekExtendedCloseTag.FindSubmatch(line); match != nil && bytes.IndexByte(match[1], '_') >= 0 {
|
||||
return p.parseMultiLine(extendedCloseTag, reader)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *underscoreRawHTMLParser) parseMultiLine(re *regexp.Regexp, reader text.Reader) gast.Node {
|
||||
startLine, startSegment := reader.Position()
|
||||
if !reader.Match(re) {
|
||||
return nil
|
||||
}
|
||||
endLine, endSegment := reader.Position()
|
||||
reader.SetPosition(startLine, startSegment)
|
||||
node := gast.NewRawHTML()
|
||||
for {
|
||||
line, segment := reader.PeekLine()
|
||||
if line == nil {
|
||||
break
|
||||
}
|
||||
lineNo, _ := reader.Position()
|
||||
start := segment.Start
|
||||
if lineNo == startLine {
|
||||
start = startSegment.Start
|
||||
}
|
||||
end := segment.Stop
|
||||
if lineNo == endLine {
|
||||
end = endSegment.Start
|
||||
}
|
||||
node.Segments.Append(text.NewSegment(start, end))
|
||||
if lineNo == endLine {
|
||||
reader.Advance(end - start)
|
||||
break
|
||||
}
|
||||
reader.AdvanceLine()
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
type underscoreHTMLBlockParser struct{}
|
||||
|
||||
func (p *underscoreHTMLBlockParser) Trigger() []byte { return []byte{'<'} }
|
||||
|
||||
func (p *underscoreHTMLBlockParser) Open(_ gast.Node, reader text.Reader, pc parser.Context) (gast.Node, parser.State) {
|
||||
line, segment := reader.PeekLine()
|
||||
pos := pc.BlockOffset()
|
||||
if pos < 0 || pos >= len(line) || line[pos] != '<' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
match := extendedBlockTag.FindSubmatchIndex(line)
|
||||
if match == nil {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
tag := string(line[match[4]:match[5]])
|
||||
if !strings.Contains(tag, "_") {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
isClose := match[2] > -1 && bytes.Equal(line[match[2]:match[3]], []byte("/"))
|
||||
hasAttrs := match[6] != match[7]
|
||||
if isClose && hasAttrs {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
node := gast.NewHTMLBlock(gast.HTMLBlockType7)
|
||||
node.Lines().Append(segment)
|
||||
reader.Advance(segment.Len() - 1)
|
||||
return node, parser.NoChildren
|
||||
}
|
||||
|
||||
func (p *underscoreHTMLBlockParser) Continue(node gast.Node, reader text.Reader, _ parser.Context) parser.State {
|
||||
line, segment := reader.PeekLine()
|
||||
if gmutil.IsBlank(line) {
|
||||
return parser.Close
|
||||
}
|
||||
node.Lines().Append(segment)
|
||||
reader.Advance(segment.Len() - 1)
|
||||
return parser.Continue | parser.NoChildren
|
||||
}
|
||||
|
||||
func (p *underscoreHTMLBlockParser) Close(gast.Node, text.Reader, parser.Context) {}
|
||||
func (p *underscoreHTMLBlockParser) CanInterruptParagraph() bool { return false }
|
||||
func (p *underscoreHTMLBlockParser) CanAcceptIndentedLine() bool { return false }
|
||||
|
||||
// ---------- Markdown-aware DocxXML containers ----------
|
||||
|
||||
type containerSpec struct {
|
||||
tag string
|
||||
}
|
||||
|
||||
var containerSpecs = map[string]*containerSpec{
|
||||
"callout": {tag: "callout"},
|
||||
"grid": {tag: "grid"},
|
||||
"column": {tag: "column"},
|
||||
"div": {tag: "div"},
|
||||
}
|
||||
|
||||
var kindContainerBlock = gast.NewNodeKind("DocxContainerBlock")
|
||||
|
||||
type containerBlock struct {
|
||||
gast.BaseBlock
|
||||
spec *containerSpec
|
||||
attrs map[string]string
|
||||
}
|
||||
|
||||
func (n *containerBlock) Kind() gast.NodeKind { return kindContainerBlock }
|
||||
func (n *containerBlock) Dump(source []byte, level int) {
|
||||
gast.DumpHelper(n, source, level, nil, nil)
|
||||
}
|
||||
|
||||
type containerBlockParser struct{}
|
||||
|
||||
func (p *containerBlockParser) Trigger() []byte { return []byte{'<'} }
|
||||
|
||||
var containerOpenTag = regexp.MustCompile(`^<([A-Za-z][A-Za-z0-9_-]*)`)
|
||||
|
||||
func (p *containerBlockParser) Open(_ gast.Node, reader text.Reader, _ parser.Context) (gast.Node, parser.State) {
|
||||
line, _ := reader.PeekLine()
|
||||
trimmed := bytes.TrimLeft(line, " \t")
|
||||
leading := len(line) - len(trimmed)
|
||||
if len(trimmed) < 2 || trimmed[0] != '<' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
match := containerOpenTag.FindSubmatch(trimmed)
|
||||
if match == nil {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
spec := containerSpecs[strings.ToLower(string(match[1]))]
|
||||
if spec == nil {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
openEnd := bytes.IndexByte(trimmed, '>')
|
||||
if openEnd < 0 || openEnd >= 1 && trimmed[openEnd-1] == '/' {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
tagEnd := len(match[0])
|
||||
node := &containerBlock{spec: spec, attrs: parseAttributes(string(trimmed[tagEnd:openEnd]))}
|
||||
reader.Advance(leading + openEnd + 1)
|
||||
return node, parser.HasChildren
|
||||
}
|
||||
|
||||
func (p *containerBlockParser) Continue(node gast.Node, reader text.Reader, _ parser.Context) parser.State {
|
||||
container := node.(*containerBlock)
|
||||
line, segment := reader.PeekLine()
|
||||
trimmed := bytes.TrimLeft(line, " \t")
|
||||
if hasCloseTagPrefix(trimmed, container.spec.tag) {
|
||||
reader.Advance(len(line) - len(trimmed) + closeTagLength(container.spec.tag))
|
||||
return parser.Close
|
||||
}
|
||||
if isXMLTagLine(trimmed) {
|
||||
indent := len(line) - len(trimmed)
|
||||
if indent > 0 && segment.Start+indent <= segment.Stop {
|
||||
reader.AdvanceAndSetPadding(indent, 0)
|
||||
}
|
||||
}
|
||||
return parser.Continue | parser.HasChildren
|
||||
}
|
||||
|
||||
func (p *containerBlockParser) Close(gast.Node, text.Reader, parser.Context) {}
|
||||
func (p *containerBlockParser) CanInterruptParagraph() bool { return true }
|
||||
func (p *containerBlockParser) CanAcceptIndentedLine() bool { return true }
|
||||
|
||||
func closeTagLength(tag string) int { return len(tag) + len("</>") }
|
||||
|
||||
func hasCloseTagPrefix(line []byte, tag string) bool {
|
||||
want := []byte("</" + tag + ">")
|
||||
return len(line) >= len(want) && bytes.EqualFold(line[:len(want)], want)
|
||||
}
|
||||
|
||||
func isXMLTagLine(line []byte) bool {
|
||||
if len(line) < 2 || line[0] != '<' {
|
||||
return false
|
||||
}
|
||||
if line[1] == '/' {
|
||||
return len(line) >= 3 && isASCIILetter(line[2])
|
||||
}
|
||||
return isASCIILetter(line[1])
|
||||
}
|
||||
|
||||
func isASCIILetter(ch byte) bool {
|
||||
return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z'
|
||||
}
|
||||
172
shortcuts/doc/internal/docxparse/model.go
Normal file
172
shortcuts/doc/internal/docxparse/model.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package docxparse parses LarkOpenCLI DocxXML and Markdown into a small,
|
||||
// offline DOM for the docs +script shortcut.
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Format is an accepted source document format.
|
||||
type Format string
|
||||
|
||||
const (
|
||||
FormatXML Format = "xml"
|
||||
FormatMarkdown Format = "markdown"
|
||||
)
|
||||
|
||||
// ParseResult is the complete result returned by Parse.
|
||||
type ParseResult struct {
|
||||
Format Format `json:"format"`
|
||||
XML string `json:"xml"`
|
||||
Profile Profile `json:"profile"`
|
||||
}
|
||||
|
||||
type nodeType uint8
|
||||
|
||||
const (
|
||||
nodeText nodeType = iota
|
||||
nodeElement
|
||||
)
|
||||
|
||||
// Node is the internal DocxXML DOM representation.
|
||||
type Node struct {
|
||||
typ nodeType
|
||||
tag string
|
||||
attrs map[string]string
|
||||
children []*Node
|
||||
text string
|
||||
parent *Node
|
||||
}
|
||||
|
||||
func newText(text string) *Node {
|
||||
return &Node{typ: nodeText, text: text}
|
||||
}
|
||||
|
||||
func newElement(tag string, attrs map[string]string) *Node {
|
||||
return &Node{typ: nodeElement, tag: tag, attrs: attrs}
|
||||
}
|
||||
|
||||
func (n *Node) addChild(child *Node) {
|
||||
if n == nil || child == nil {
|
||||
return
|
||||
}
|
||||
child.parent = n
|
||||
n.children = append(n.children, child)
|
||||
}
|
||||
|
||||
func (n *Node) writeXML(out *strings.Builder) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
if n.typ == nodeText {
|
||||
out.WriteString(escapeXMLText(n.text))
|
||||
return
|
||||
}
|
||||
|
||||
out.WriteByte('<')
|
||||
out.WriteString(n.tag)
|
||||
keys := make([]string, 0, len(n.attrs))
|
||||
for key := range n.attrs {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
wi, iWeighted := attributeWeight[keys[i]]
|
||||
wj, jWeighted := attributeWeight[keys[j]]
|
||||
switch {
|
||||
case iWeighted && jWeighted && wi != wj:
|
||||
return wi < wj
|
||||
case iWeighted != jWeighted:
|
||||
return iWeighted
|
||||
default:
|
||||
return keys[i] < keys[j]
|
||||
}
|
||||
})
|
||||
for _, key := range keys {
|
||||
out.WriteByte(' ')
|
||||
out.WriteString(key)
|
||||
out.WriteString(`="`)
|
||||
out.WriteString(escapeXMLAttr(n.attrs[key]))
|
||||
out.WriteByte('"')
|
||||
}
|
||||
|
||||
if isVoidTag(n.tag) {
|
||||
out.WriteString("/>")
|
||||
return
|
||||
}
|
||||
out.WriteByte('>')
|
||||
for _, child := range n.children {
|
||||
child.writeXML(out)
|
||||
}
|
||||
out.WriteString("</")
|
||||
out.WriteString(n.tag)
|
||||
out.WriteByte('>')
|
||||
}
|
||||
|
||||
func renderNodes(nodes []*Node) string {
|
||||
var out strings.Builder
|
||||
for _, node := range nodes {
|
||||
node.writeXML(&out)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
var attributeWeight = map[string]int{
|
||||
"id": 0,
|
||||
"name": 1,
|
||||
"top-block-id": 2,
|
||||
"parent-block-path": 3,
|
||||
"mode": 4,
|
||||
"start-block-id": 5,
|
||||
"end-block-id": 6,
|
||||
"hit-block-ids": 7,
|
||||
}
|
||||
|
||||
func escapeXMLText(value string) string {
|
||||
if !strings.ContainsAny(value, "&<>") {
|
||||
return value
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(value) + 8)
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
out.WriteString("&")
|
||||
case '<':
|
||||
out.WriteString("<")
|
||||
case '>':
|
||||
out.WriteString(">")
|
||||
default:
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func escapeXMLAttr(value string) string {
|
||||
if !strings.ContainsAny(value, "&<>\"'") {
|
||||
return value
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(value) + 8)
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
out.WriteString("&")
|
||||
case '<':
|
||||
out.WriteString("<")
|
||||
case '>':
|
||||
out.WriteString(">")
|
||||
case '"':
|
||||
out.WriteString(""")
|
||||
case '\'':
|
||||
out.WriteString("'")
|
||||
default:
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
488
shortcuts/doc/internal/docxparse/parse_test.go
Normal file
488
shortcuts/doc/internal/docxparse/parse_test.go
Normal file
@@ -0,0 +1,488 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseXMLBuildsBlockDistribution(t *testing.T) {
|
||||
result, err := Parse(`<title>T</title><p>P</p><ul><li>A</li><li>B</li></ul>`, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != `<title>T</title><p>P</p><ul><li>A</li><li>B</li></ul>` {
|
||||
t.Fatalf("XML = %q", result.XML)
|
||||
}
|
||||
if result.Profile.BlockCount != 5 {
|
||||
t.Fatalf("block total = %d, want 5", result.Profile.BlockCount)
|
||||
}
|
||||
shares := map[string]BlockShare{}
|
||||
for _, share := range result.Profile.Blocks {
|
||||
shares[share.Type] = share
|
||||
}
|
||||
if got := shares["li"]; got.Count != 2 || got.Ratio != 0.4 {
|
||||
t.Fatalf("li share = %+v, want count=2 ratio=0.4", got)
|
||||
}
|
||||
for _, typ := range []string{"title", "p", "ul"} {
|
||||
if got := shares[typ]; got.Count != 1 || got.Ratio != 0.2 {
|
||||
t.Errorf("%s share = %+v, want count=1 ratio=0.2", typ, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLRejectsInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
}{
|
||||
{name: "unsupported tag", source: `<unknown>x</unknown>`},
|
||||
{name: "missing closing tag", source: `<p>one`},
|
||||
{name: "invalid nesting", source: `<span>x<table><tr><td>y</td></tr></table></span>`},
|
||||
{name: "malformed block id", source: `<block_id="8,9"/>`},
|
||||
{name: "unterminated cdata", source: `<code><![CDATA[a < b</code>`},
|
||||
{name: "tag spacing", source: `< p>text< / p>`},
|
||||
{name: "self closing slash spacing", source: `<p/ >`},
|
||||
{name: "unquoted attribute", source: `<p align=center>text</p>`},
|
||||
{name: "invalid entity", source: `<p>one &unknown;</p>`},
|
||||
{name: "invalid attribute entity", source: `<img href="https://example.com/&unknown;"/>`},
|
||||
{name: "missing required ancestor", source: `<td>cell</td>`},
|
||||
{name: "missing required attribute", source: `<img/>`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := Parse(tt.source, FormatXML); err == nil {
|
||||
t.Fatalf("Parse(%q) succeeded, want validation error", tt.source)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAutoDetectsXMLAndMarkdown(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
blocks int
|
||||
}{
|
||||
{name: "xml", source: `<title>T</title><p>P</p>`, blocks: 2},
|
||||
{name: "markdown", source: "# T\n\nP", blocks: 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile, err := ParseAuto(tt.source)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAuto() error = %v", err)
|
||||
}
|
||||
if profile.BlockCount != tt.blocks {
|
||||
t.Fatalf("profile = %+v, want %d blocks", profile, tt.blocks)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAutoDoesNotTreatMalformedXMLAsMarkdown(t *testing.T) {
|
||||
if _, err := ParseAuto(`<p>text`); err == nil {
|
||||
t.Fatal("ParseAuto() succeeded, want malformed XML error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLAcceptsPublicTagAliasesWithoutChangingInput(t *testing.T) {
|
||||
source := `<P>one<strong>two</strong><br></P><image href="https://example.com/image.png">`
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original %q", result.XML, source)
|
||||
}
|
||||
if result.Profile.BlockCount != 2 {
|
||||
t.Fatalf("profile = %+v, want p and img blocks", result.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLAcceptsPublicAttributeAliasesWithoutChangingInput(t *testing.T) {
|
||||
source := `<callout color="blue" icon="💡"><p>x</p></callout><at id="ou_legacy"></at><img url="https://example.com/image.png"/>`
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original %q", result.XML, source)
|
||||
}
|
||||
if result.Profile.BlockCount != 3 {
|
||||
t.Fatalf("profile = %+v, want callout, p, and img blocks", result.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLAcceptsBareAmpersandsInAttributes(t *testing.T) {
|
||||
source := `<block_insert><parameter><block_id>-1</block_id><content><img href="https://picsum.photos/320/200?seed=lark-cli&raw=1"/></content></parameter></block_insert>`
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original %q", result.XML, source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeXMLAttributeAmpersandsPreservesEntityReferences(t *testing.T) {
|
||||
source := `https://example.com?a=1&b=2&c=3&d=4&e=5&unknown;`
|
||||
want := `https://example.com?a=1&b=2&c=3&d=4&e=5&unknown;`
|
||||
if got := normalizeXMLAttributeAmpersands(source); got != want {
|
||||
t.Fatalf("normalizeXMLAttributeAmpersands() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLPreservesValidCDATA(t *testing.T) {
|
||||
source := `<code><![CDATA[a < b && c > d]]></code>`
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original %q", result.XML, source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLPreservesUTF8BOM(t *testing.T) {
|
||||
source := "\uFEFF<p>text</p>"
|
||||
result, err := Parse(source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != source {
|
||||
t.Fatalf("XML = %q, want original input", result.XML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownConvertsLarkOpenCLIBlocks(t *testing.T) {
|
||||
source := "# 标题\n\nHello **world**.\n\n- [x] Done\n- [ ] Todo\n\n" +
|
||||
"| A | B |\n| --- | --- |\n| 1 | 2 |\n\n" +
|
||||
"```go\nfmt.Println(\"x\")\n```\n\n$E=mc^2$\n"
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`<h1>标题</h1>`,
|
||||
`<p>Hello <b>world</b>.</p>`,
|
||||
`<checkbox done="true">Done</checkbox>`,
|
||||
`<checkbox done="false">Todo</checkbox>`,
|
||||
`<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>`,
|
||||
`<pre lang="go"><code>fmt.Println("x")</code></pre>`,
|
||||
`<p><latex>E=mc^2</latex></p>`,
|
||||
} {
|
||||
if !strings.Contains(result.XML, fragment) {
|
||||
t.Errorf("XML missing %q:\n%s", fragment, result.XML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownPreservesLineBreakSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "soft breaks become spaces",
|
||||
source: "**文号:桂汛旱指〔2026〕17号**\n**签发人:XXX**\n**发布日期:2026年7月13日**",
|
||||
want: `<p><b>文号:桂汛旱指〔2026〕17号</b> <b>签发人:XXX</b> <b>发布日期:2026年7月13日</b></p>`,
|
||||
},
|
||||
{
|
||||
name: "hard breaks remain line breaks",
|
||||
source: "**文号:A** \n**签发人:B**",
|
||||
want: `<p><b>文号:A</b><br/><b>签发人:B</b></p>`,
|
||||
},
|
||||
{
|
||||
name: "blank lines remain paragraph breaks",
|
||||
source: "**文号:A**\n\n**签发人:B**",
|
||||
want: `<p><b>文号:A</b></p><p><b>签发人:B</b></p>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := Parse(tt.source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != tt.want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownContainerKeepsMarkdownChildren(t *testing.T) {
|
||||
source := "<callout emoji=\"💡\">\n\n## Note\n\n- item\n\n</callout>\n"
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
want := `<callout emoji="💡"><h2>Note</h2><ul><li>item</li></ul></callout>`
|
||||
if result.XML != want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownMatchesLarkOpenCLIFixtures(t *testing.T) {
|
||||
t.Run("deep nested list", func(t *testing.T) {
|
||||
result, err := Parse("1. 第一层\n - 第二层\n - 第三层\n - 第四层\n", FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if strings.Contains(result.XML, "<pre>") || strings.Contains(result.XML, "<code>") || !strings.Contains(result.XML, "第四层") {
|
||||
t.Fatalf("nested list converted incorrectly: %s", result.XML)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fenced mermaid", func(t *testing.T) {
|
||||
result, err := Parse("```mermaid\nflowchart LR\nA-->B\n```", FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
want := `<whiteboard type="mermaid">flowchart LR<br/>A-->B</whiteboard>`
|
||||
if result.XML != want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("raw whiteboard source", func(t *testing.T) {
|
||||
source := "<whiteboard type=\"mermaid\">\nflowchart LR\n A --> B\n</whiteboard>"
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
want := `<whiteboard type="mermaid">flowchart LR<br/> A --> B</whiteboard>`
|
||||
if result.XML != want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("raw code stays literal", func(t *testing.T) {
|
||||
source := "<code lang=\"go\">\nif a < b && c > d {\n fmt.Println(\"**raw**\")\n}\n</code>"
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
want := `<code lang="go">if a < b && c > d {<br/> fmt.Println("**raw**")<br/>}</code>`
|
||||
if result.XML != want {
|
||||
t.Fatalf("XML = %q, want %q", result.XML, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("underscore tags", func(t *testing.T) {
|
||||
result, err := Parse(`text <synced_reference src-block-id="abc" src-token="def"/> more`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(result.XML, `<synced_reference`) || strings.Contains(result.XML, `<synced_reference`) {
|
||||
t.Fatalf("underscore tag was not preserved: %s", result.XML)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canonical user cite", func(t *testing.T) {
|
||||
result, err := Parse(`hello <cite type="user" user-id="ou_user"></cite>`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`<cite`, `type="user"`, `user-id="ou_user"`} {
|
||||
if !strings.Contains(result.XML, want) {
|
||||
t.Errorf("XML missing %q: %s", want, result.XML)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public tag alias converts to canonical XML", func(t *testing.T) {
|
||||
result, err := Parse(`hello <strong>world</strong>`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != `<p>hello <b>world</b></p>` {
|
||||
t.Fatalf("XML = %q", result.XML)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public cite alias converts attributes", func(t *testing.T) {
|
||||
result, err := Parse(`hello <at id="ou_legacy"></at>`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != `<p>hello <cite type="user" user-id="ou_legacy"></cite></p>` {
|
||||
t.Fatalf("XML = %q", result.XML)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("markdown backslash escapes", func(t *testing.T) {
|
||||
result, err := Parse(`"source\_token": \[abc\] path\\to`, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`source_token`, `[abc]`, `path\to`} {
|
||||
if !strings.Contains(result.XML, want) {
|
||||
t.Errorf("XML missing %q: %s", want, result.XML)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adjacent CJK emphasis", func(t *testing.T) {
|
||||
source := `***你好。***S 和 ~~再见。~~T。**agent team 做 brownfield 项目,带来的感知会强烈得多**——前提。**这个时刻,才是真正属于 agent team 的"闪光时刻"。**翟霖`
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`<em><b>你好。</b></em>S`,
|
||||
`<del>再见。</del>T`,
|
||||
`<b>agent team 做 brownfield 项目,带来的感知会强烈得多</b>`,
|
||||
`<b>这个时刻,才是真正属于 agent team 的"闪光时刻"。</b>翟霖`,
|
||||
} {
|
||||
if !strings.Contains(result.XML, want) {
|
||||
t.Errorf("XML missing %q: %s", want, result.XML)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("div parses markdown children", func(t *testing.T) {
|
||||
result, err := Parse("<div>\n\n**bold**\n\n</div>", FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if result.XML != `<div><p><b>bold</b></p></div>` {
|
||||
t.Fatalf("XML = %q", result.XML)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPreprocessCJKAdjacentMarkupUsesRuneOffsetsAfterRawBlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lineEnding string
|
||||
final string
|
||||
}{
|
||||
{name: "EOF", lineEnding: "\n"},
|
||||
{name: "LF", lineEnding: "\n", final: "\n"},
|
||||
{name: "CRLF", lineEnding: "\r\n", final: "\r\n"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
source := "<code>**raw**" + tt.lineEnding + "Ⱥ</code>**你好。**S" + tt.final
|
||||
want := "<code>**raw**" + tt.lineEnding + "Ⱥ</code><b>你好。</b>S" + tt.final
|
||||
if got := preprocessCJKAdjacentMarkup(source); got != want {
|
||||
t.Fatalf("preprocessCJKAdjacentMarkup() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextProfileMatchesLarkOpenCLIContract(t *testing.T) {
|
||||
result, err := Parse(`<title>标题</title><p>一个苹果是 an apple。</p>`, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
profile := result.Profile
|
||||
if profile.WordCount != 10 || profile.CharCount != 15 {
|
||||
t.Fatalf("profile = %+v, want word_count=10 char_count=15", profile)
|
||||
}
|
||||
if profile.Breakdown.HanChars != 7 || profile.Breakdown.EnglishWords != 2 || profile.Breakdown.ChinesePunctuations != 1 {
|
||||
t.Fatalf("breakdown = %+v", profile.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextProfileMatchesAuthoringCounterCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
words int
|
||||
chars int
|
||||
blocks int
|
||||
english int
|
||||
numbers int
|
||||
han int
|
||||
listItems int
|
||||
}{
|
||||
{
|
||||
name: "english number and punctuation",
|
||||
source: `<p>Hello world 123.45。</p>`,
|
||||
words: 4, chars: 17, blocks: 1, english: 2, numbers: 1,
|
||||
},
|
||||
{
|
||||
name: "list and checkbox markers",
|
||||
source: `<ul><li>甲</li><li>two</li></ul><checkbox done="true">完成</checkbox>`,
|
||||
words: 7, chars: 9, blocks: 4, english: 1, han: 3, listItems: 2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := Parse(tt.source, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
profile := result.Profile
|
||||
if profile.WordCount != tt.words || profile.CharCount != tt.chars || profile.BlockCount != tt.blocks {
|
||||
t.Fatalf("profile = %+v, want words=%d chars=%d blocks=%d", profile, tt.words, tt.chars, tt.blocks)
|
||||
}
|
||||
if profile.Breakdown.EnglishWords != tt.english || profile.Breakdown.NumberWords != tt.numbers || profile.Breakdown.HanChars != tt.han {
|
||||
t.Fatalf("breakdown = %+v", profile.Breakdown)
|
||||
}
|
||||
if got := blockCountForTest(profile.Blocks, "li"); got != tt.listItems {
|
||||
t.Fatalf("li count = %d, want %d", got, tt.listItems)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextProfileUsesVisibleAttributeFallbacks(t *testing.T) {
|
||||
result, err := Parse(`<p text="Hello"/><p><span title="world"/></p><img href="https://example.com/image.png" caption="图"/>`, FormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
profile := result.Profile
|
||||
if profile.WordCount != 3 || profile.CharCount != 11 {
|
||||
t.Fatalf("profile = %+v, want word_count=3 char_count=11", profile)
|
||||
}
|
||||
if profile.Breakdown.EnglishWords != 2 || profile.Breakdown.HanChars != 1 {
|
||||
t.Fatalf("breakdown = %+v", profile.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsUnsafeXMLDeclarations(t *testing.T) {
|
||||
_, err := Parse(`<!DOCTYPE foo [<!ENTITY x "value">]><p>&x;</p>`, FormatXML)
|
||||
if err == nil || !strings.Contains(err.Error(), "DOCTYPE or ENTITY") {
|
||||
t.Fatalf("Parse() error = %v, want unsafe declaration rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsInvalidUTF8(t *testing.T) {
|
||||
_, err := Parse(string([]byte{'<', 'p', '>', 0xff, '<', '/', 'p', '>'}), FormatXML)
|
||||
if err == nil || !strings.Contains(err.Error(), "valid UTF-8") {
|
||||
t.Fatalf("Parse() error = %v, want UTF-8 rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsExcessiveNesting(t *testing.T) {
|
||||
source := strings.Repeat("<span>", MaxNestingDepth+1)
|
||||
_, err := Parse(source, FormatXML)
|
||||
if err == nil || !strings.Contains(err.Error(), "nesting exceeds") {
|
||||
t.Fatalf("Parse() error = %v, want nesting limit rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXMLRejectsNestedInvalidTagStarts(t *testing.T) {
|
||||
if _, err := Parse(`<<<<p>text</p>`, FormatXML); err == nil {
|
||||
t.Fatal("Parse() succeeded, want invalid XML token error")
|
||||
}
|
||||
}
|
||||
|
||||
func blockCountForTest(blocks []BlockShare, typ string) int {
|
||||
for _, block := range blocks {
|
||||
if block.Type == typ {
|
||||
return block.Count
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
397
shortcuts/doc/internal/docxparse/profile.go
Normal file
397
shortcuts/doc/internal/docxparse/profile.go
Normal file
@@ -0,0 +1,397 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Profile describes LarkOpenCLI document structure and visible text without
|
||||
// requiring callers to inspect the full XML.
|
||||
type Profile struct {
|
||||
WordCount int `json:"word_count"`
|
||||
CharCount int `json:"char_count"`
|
||||
Breakdown TextBreakdown `json:"breakdown"`
|
||||
BlockCount int `json:"block_count"`
|
||||
Blocks []BlockShare `json:"blocks"`
|
||||
}
|
||||
|
||||
// BlockShare reports one LarkOpenCLI block type's count and share. Structural
|
||||
// and inline-only tags are intentionally excluded.
|
||||
type BlockShare struct {
|
||||
Type string `json:"type"`
|
||||
Count int `json:"count"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
}
|
||||
|
||||
// TextProfile is the internal result of the LarkOpenCLI semantic counter.
|
||||
type TextProfile struct {
|
||||
WordCount int `json:"word_count"`
|
||||
CharCount int `json:"char_count"`
|
||||
Breakdown TextBreakdown `json:"breakdown"`
|
||||
}
|
||||
|
||||
type TextBreakdown struct {
|
||||
HanChars int `json:"han_chars"`
|
||||
EnglishWords int `json:"english_words"`
|
||||
NumberWords int `json:"number_words"`
|
||||
ChinesePunctuations int `json:"chinese_punctuations"`
|
||||
EnglishLetters int `json:"english_letters"`
|
||||
Digits int `json:"digits"`
|
||||
EnglishPunctuations int `json:"english_punctuations"`
|
||||
SymbolWords int `json:"symbol_words"`
|
||||
SymbolChars int `json:"symbol_chars"`
|
||||
}
|
||||
|
||||
// Parse validates XML or converts Markdown to DocxXML, then builds its
|
||||
// structure and visible-text profile.
|
||||
func Parse(source string, format Format) (ParseResult, error) {
|
||||
var (
|
||||
nodes []*Node
|
||||
outputXML string
|
||||
err error
|
||||
)
|
||||
switch format {
|
||||
case FormatXML:
|
||||
nodes, err = parseXML(source)
|
||||
outputXML = source
|
||||
case FormatMarkdown:
|
||||
nodes, err = parseMarkdown(source)
|
||||
default:
|
||||
return ParseResult{}, fmt.Errorf("unsupported input format %q", format)
|
||||
}
|
||||
if err != nil {
|
||||
return ParseResult{}, err
|
||||
}
|
||||
if err := validateStructure(nodes); err != nil {
|
||||
return ParseResult{}, err
|
||||
}
|
||||
if format == FormatMarkdown {
|
||||
outputXML = renderNodes(nodes)
|
||||
}
|
||||
|
||||
return ParseResult{
|
||||
Format: format,
|
||||
XML: outputXML,
|
||||
Profile: buildProfile(nodes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseAuto detects XML versus Markdown from the content and returns only the
|
||||
// document profile. XML-like input is parsed strictly; all other input is
|
||||
// interpreted as Markdown.
|
||||
func ParseAuto(source string) (Profile, error) {
|
||||
result, err := Parse(source, detectFormat(source))
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
return result.Profile, nil
|
||||
}
|
||||
|
||||
// MarkdownToXML converts Markdown to canonical LarkOpenCLI XML.
|
||||
func MarkdownToXML(source string) (string, error) {
|
||||
result, err := Parse(source, FormatMarkdown)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.XML, nil
|
||||
}
|
||||
|
||||
func detectFormat(source string) Format {
|
||||
trimmed := strings.TrimSpace(strings.TrimPrefix(source, "\uFEFF"))
|
||||
if strings.HasPrefix(trimmed, "<") {
|
||||
return FormatXML
|
||||
}
|
||||
return FormatMarkdown
|
||||
}
|
||||
|
||||
func validateStructure(nodes []*Node) error {
|
||||
type frame struct {
|
||||
node *Node
|
||||
exit bool
|
||||
}
|
||||
frames := make([]frame, 0, len(nodes))
|
||||
for i := len(nodes) - 1; i >= 0; i-- {
|
||||
frames = append(frames, frame{node: nodes[i]})
|
||||
}
|
||||
ancestors := map[string]int{}
|
||||
depth := 0
|
||||
for len(frames) > 0 {
|
||||
current := frames[len(frames)-1]
|
||||
frames = frames[:len(frames)-1]
|
||||
node := current.node
|
||||
if node == nil || node.typ != nodeElement {
|
||||
continue
|
||||
}
|
||||
if current.exit {
|
||||
ancestors[node.tag]--
|
||||
depth--
|
||||
continue
|
||||
}
|
||||
if depth >= MaxNestingDepth {
|
||||
return fmt.Errorf("document nesting exceeds limit %d at <%s>", MaxNestingDepth, node.tag)
|
||||
}
|
||||
if err := validateRequiredAttributes(node); err != nil {
|
||||
return err
|
||||
}
|
||||
if required := requiredAncestorTags[node.tag]; len(required) > 0 {
|
||||
matched := false
|
||||
for tag := range required {
|
||||
if ancestors[tag] > 0 {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
allowed := make([]string, 0, len(required))
|
||||
for tag := range required {
|
||||
allowed = append(allowed, tag)
|
||||
}
|
||||
sort.Strings(allowed)
|
||||
return fmt.Errorf("LarkOpenCLI tag <%s> requires an ancestor in [%s]", node.tag, strings.Join(allowed, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
ancestors[node.tag]++
|
||||
depth++
|
||||
frames = append(frames, frame{node: node, exit: true})
|
||||
for i := len(node.children) - 1; i >= 0; i-- {
|
||||
frames = append(frames, frame{node: node.children[i]})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRequiredAttributes(node *Node) error {
|
||||
for _, attr := range requiredAttributes[node.tag] {
|
||||
if strings.TrimSpace(node.attrs[attr]) == "" {
|
||||
return fmt.Errorf("LarkOpenCLI tag <%s> requires attribute %q", node.tag, attr)
|
||||
}
|
||||
}
|
||||
for _, alternatives := range requiredAnyAttributes[node.tag] {
|
||||
matched := false
|
||||
for _, attr := range alternatives {
|
||||
if strings.TrimSpace(node.attrs[attr]) != "" {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("LarkOpenCLI tag <%s> requires one of attributes [%s]", node.tag, strings.Join(alternatives, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildProfile(nodes []*Node) Profile {
|
||||
counts := map[string]int{}
|
||||
total := 0
|
||||
var walk func(*Node)
|
||||
walk = func(node *Node) {
|
||||
if node == nil || node.typ != nodeElement {
|
||||
return
|
||||
}
|
||||
layout := layoutOf(node.tag)
|
||||
isBlock := layout == layoutBlock || layout == layoutDual && node.parent == nil
|
||||
if isBlock {
|
||||
counts[node.tag]++
|
||||
total++
|
||||
}
|
||||
for _, child := range node.children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
for _, node := range nodes {
|
||||
walk(node)
|
||||
}
|
||||
|
||||
distribution := make([]BlockShare, 0, len(counts))
|
||||
for typ, count := range counts {
|
||||
ratio := 0.0
|
||||
if total > 0 {
|
||||
ratio = math.Round(float64(count)/float64(total)*1_000_000) / 1_000_000
|
||||
}
|
||||
distribution = append(distribution, BlockShare{Type: typ, Count: count, Ratio: ratio})
|
||||
}
|
||||
sort.Slice(distribution, func(i, j int) bool {
|
||||
if distribution[i].Count != distribution[j].Count {
|
||||
return distribution[i].Count > distribution[j].Count
|
||||
}
|
||||
return distribution[i].Type < distribution[j].Type
|
||||
})
|
||||
segments := extractSegments(nodes)
|
||||
stats := newTextCounter().countSegments(segments)
|
||||
return Profile{
|
||||
WordCount: stats.WordCount,
|
||||
CharCount: stats.CharCount,
|
||||
Breakdown: stats.Breakdown,
|
||||
BlockCount: total,
|
||||
Blocks: distribution,
|
||||
}
|
||||
}
|
||||
|
||||
type segmentKind uint8
|
||||
|
||||
const (
|
||||
segmentText segmentKind = iota
|
||||
segmentMarker
|
||||
segmentCode
|
||||
)
|
||||
|
||||
type textSegment struct {
|
||||
text string
|
||||
kind segmentKind
|
||||
}
|
||||
|
||||
var ignoredResourceTags = map[string]bool{
|
||||
"whiteboard": true, "sheet": true, "source": true, "chat_card": true,
|
||||
"base_refer": true, "bitable": true, "synced_reference": true,
|
||||
"poll": true, "isv": true, "mindnote": true, "sub-page-list": true,
|
||||
"okr": true, "html5-block": true,
|
||||
}
|
||||
|
||||
var ignoredInlineTags = map[string]bool{
|
||||
"button": true, "cite": true, "latex": true, "bookmark": true,
|
||||
}
|
||||
|
||||
func extractSegments(nodes []*Node) []textSegment {
|
||||
var segments []textSegment
|
||||
for _, node := range nodes {
|
||||
extractNodeSegments(node, &segments)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
func extractNodeSegments(node *Node, segments *[]textSegment) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.typ == nodeText {
|
||||
if strings.TrimSpace(node.text) != "" {
|
||||
*segments = append(*segments, textSegment{text: node.text})
|
||||
}
|
||||
return
|
||||
}
|
||||
if ignoredInlineTags[node.tag] || ignoredResourceTags[node.tag] {
|
||||
return
|
||||
}
|
||||
if node.tag == "task" {
|
||||
return
|
||||
}
|
||||
if node.tag == "synced-source" && len(node.children) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
switch node.tag {
|
||||
case "ul", "ol":
|
||||
sequence := 1
|
||||
for _, child := range node.children {
|
||||
if child.typ == nodeElement && child.tag == "li" {
|
||||
if node.tag == "ul" {
|
||||
*segments = append(*segments, textSegment{text: "•", kind: segmentMarker})
|
||||
} else {
|
||||
marker := sequence
|
||||
if raw := child.attrs["seq"]; raw != "" {
|
||||
if _, err := fmt.Sscanf(raw, "%d", &marker); err == nil {
|
||||
sequence = marker
|
||||
}
|
||||
}
|
||||
*segments = append(*segments, textSegment{text: fmt.Sprintf("%d.", marker)})
|
||||
sequence++
|
||||
}
|
||||
}
|
||||
extractNodeSegments(child, segments)
|
||||
}
|
||||
return
|
||||
case "checkbox":
|
||||
marker := "☐"
|
||||
if node.attrs["done"] == "true" {
|
||||
marker = "☑"
|
||||
}
|
||||
*segments = append(*segments, textSegment{text: marker, kind: segmentMarker})
|
||||
}
|
||||
|
||||
kind := segmentText
|
||||
if node.tag == "pre" || node.tag == "code" && (node.parent == nil || node.parent.tag != "p") {
|
||||
kind = segmentCode
|
||||
}
|
||||
text := visibleInlineText(node)
|
||||
if strings.TrimSpace(text) == "" && !hasBlockChildren(node) {
|
||||
if node.tag == "img" {
|
||||
text = node.attrs["caption"]
|
||||
} else {
|
||||
text = firstNonEmpty(node.attrs["text"], node.attrs["name"], node.attrs["title"], node.attrs["alt"], node.attrs["caption"])
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(text) != "" {
|
||||
*segments = append(*segments, textSegment{text: text, kind: kind})
|
||||
}
|
||||
|
||||
for _, child := range node.children {
|
||||
if child.typ != nodeElement || isInlineForExtraction(child.tag) {
|
||||
continue
|
||||
}
|
||||
extractNodeSegments(child, segments)
|
||||
}
|
||||
}
|
||||
|
||||
func visibleInlineText(node *Node) string {
|
||||
var out strings.Builder
|
||||
var walk func(*Node)
|
||||
walk = func(current *Node) {
|
||||
if current.typ == nodeText {
|
||||
out.WriteString(current.text)
|
||||
return
|
||||
}
|
||||
if current != node && !isInlineForExtraction(current.tag) {
|
||||
return
|
||||
}
|
||||
if ignoredInlineTags[current.tag] {
|
||||
return
|
||||
}
|
||||
if current.tag == "br" {
|
||||
out.WriteByte('\n')
|
||||
return
|
||||
}
|
||||
if current != node {
|
||||
if display := firstNonEmpty(current.attrs["text"], current.attrs["name"], current.attrs["title"], current.attrs["alt"]); display != "" {
|
||||
out.WriteString(display)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, child := range current.children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
for _, child := range node.children {
|
||||
walk(child)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func hasBlockChildren(node *Node) bool {
|
||||
for _, child := range node.children {
|
||||
if child.typ == nodeElement && !isInlineForExtraction(child.tag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isInlineForExtraction(tag string) bool {
|
||||
layout := layoutOf(tag)
|
||||
return layout == layoutInline || layout == layoutDual
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
268
shortcuts/doc/internal/docxparse/schema.go
Normal file
268
shortcuts/doc/internal/docxparse/schema.go
Normal file
@@ -0,0 +1,268 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docxparse
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type tagLayout string
|
||||
|
||||
const (
|
||||
layoutBlock tagLayout = "block"
|
||||
layoutInline tagLayout = "inline"
|
||||
layoutDual tagLayout = "dual"
|
||||
layoutStructural tagLayout = "structural"
|
||||
layoutCommand tagLayout = "command"
|
||||
)
|
||||
|
||||
type tagSpec struct {
|
||||
canonical string
|
||||
layout tagLayout
|
||||
}
|
||||
|
||||
var tagSpecs = map[string]tagSpec{}
|
||||
|
||||
// tagAliases mirrors the public compatibility aliases declared by the
|
||||
// LarkOpenCLI SDK. Parsing keeps the caller's XML unchanged; aliases are only
|
||||
// canonicalized in the in-memory tree used for profiling and Markdown output.
|
||||
var tagAliases = map[string]string{
|
||||
"strong": "b",
|
||||
"text": "span",
|
||||
"equation": "latex",
|
||||
"lark-table": "table",
|
||||
"lark-tr": "tr",
|
||||
"lark-td": "td",
|
||||
"image": "img",
|
||||
"reference-synced": "synced_reference",
|
||||
"source-synced": "synced-source",
|
||||
"at": "cite",
|
||||
"chat-card": "chat_card",
|
||||
"folder_manager": "folder-manager",
|
||||
}
|
||||
|
||||
type attributeAliasRule struct {
|
||||
canonical string
|
||||
transform func(string) (string, bool)
|
||||
}
|
||||
|
||||
var commonAttributeAliases = map[string]attributeAliasRule{
|
||||
"color": {canonical: "text-color"},
|
||||
"textcolor": {canonical: "text-color"},
|
||||
"text_color": {canonical: "text-color"},
|
||||
"bgcolor": {canonical: "background-color"},
|
||||
"background_color": {canonical: "background-color"},
|
||||
}
|
||||
|
||||
var tagAttributeAliases = map[string]map[string]attributeAliasRule{
|
||||
"img": {
|
||||
"url": {canonical: "href"},
|
||||
"file_key": {canonical: "img_key"},
|
||||
},
|
||||
"callout": {
|
||||
"color": {canonical: "background-color"},
|
||||
"icon": {canonical: "emoji"},
|
||||
},
|
||||
"column": {
|
||||
"width": {canonical: "width-ratio", transform: normalizeWidthRatio},
|
||||
},
|
||||
"chat_card": {
|
||||
"id": {canonical: "chat-id", transform: requireChatID},
|
||||
},
|
||||
"cite": {
|
||||
"user_id": {canonical: "user-id"},
|
||||
},
|
||||
}
|
||||
|
||||
var rawTagAttributeAliases = map[string]map[string]attributeAliasRule{
|
||||
"at": {
|
||||
"id": {canonical: "user-id"},
|
||||
"user_id": {canonical: "user-id"},
|
||||
},
|
||||
}
|
||||
|
||||
var requiredAttributes = map[string][]string{
|
||||
"task": {"task-id"},
|
||||
}
|
||||
|
||||
var requiredAnyAttributes = map[string][][]string{
|
||||
"img": {{"src", "img_key", "href"}},
|
||||
"whiteboard": {{"token", "type"}},
|
||||
"chat_card": {{"token", "chat-id"}},
|
||||
"bookmark": {{"href", "name"}},
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerTags(layoutBlock,
|
||||
"title", "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "h9", "p",
|
||||
"div", "ul", "ol", "li", "blockquote", "grid", "column", "table", "thead",
|
||||
"tbody", "tfoot", "tr", "hr", "pre", "img", "source", "bitable", "sheet",
|
||||
"mindnote", "whiteboard", "base_refer", "synced_reference", "isv", "html5-block",
|
||||
"view", "synced-source", "readonly-block", "figure", "callout", "checkbox",
|
||||
"chat_card", "okr", "okr-objective", "okr-key-result", "okr-progress", "poll",
|
||||
"agenda", "folder-manager", "sub-page-list", "wiki_catalog", "wiki_recent_update",
|
||||
"chart-embedded", "chart-refer-host-perm", "chart_embedded", "chart_refer_host_perm",
|
||||
"bookmark", "task", "vc-tabs", "vc-summary-tab", "vc-transcribe-tab", "append",
|
||||
)
|
||||
registerTags(layoutInline, "b", "em", "u", "del", "i", "span", "br", "inline-file", "mention-date", "cite", "button", "time", "a")
|
||||
registerTags(layoutDual, "latex", "code")
|
||||
registerTags(layoutStructural, "th", "td", "colgroup", "col", "sub-page")
|
||||
registerTags(layoutCommand,
|
||||
"comment", "block_delete", "str_delete", "str_replace", "block_replace", "block_insert",
|
||||
"block_move", "block_copy_insert_after", "src_block_ids", "create", "answer", "response",
|
||||
"identifier", "genre", "anchor", "type", "revision", "pattern", "replacement",
|
||||
"replace_content", "action", "content", "parameter", "generation", "block_id",
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
func registerTags(layout tagLayout, tags ...string) {
|
||||
for _, tag := range tags {
|
||||
tagSpecs[tag] = tagSpec{canonical: tag, layout: layout}
|
||||
}
|
||||
}
|
||||
|
||||
func lookupTag(raw string) (tagSpec, bool) {
|
||||
key := strings.ToLower(strings.TrimSpace(raw))
|
||||
if canonical, ok := tagAliases[key]; ok {
|
||||
key = canonical
|
||||
}
|
||||
spec, ok := tagSpecs[key]
|
||||
if !ok {
|
||||
return tagSpec{}, false
|
||||
}
|
||||
return spec, true
|
||||
}
|
||||
|
||||
func layoutOf(tag string) tagLayout {
|
||||
spec, ok := lookupTag(tag)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return spec.layout
|
||||
}
|
||||
|
||||
var voidTags = map[string]bool{
|
||||
"br": true,
|
||||
"col": true,
|
||||
"hr": true,
|
||||
"img": true,
|
||||
"source": true,
|
||||
"sub-page": true,
|
||||
}
|
||||
|
||||
func isVoidTag(tag string) bool { return voidTags[tag] }
|
||||
|
||||
var preserveSpaceTags = map[string]bool{
|
||||
"title": true, "h1": true, "h2": true, "h3": true, "h4": true,
|
||||
"h5": true, "h6": true, "h7": true, "h8": true, "h9": true,
|
||||
"p": true, "i": true, "b": true, "em": true, "u": true, "del": true,
|
||||
"code": true, "li": true, "a": true, "span": true,
|
||||
}
|
||||
|
||||
var strictPhrasingTags = map[string]bool{
|
||||
"title": true, "span": true, "b": true, "em": true,
|
||||
"u": true, "del": true, "a": true,
|
||||
}
|
||||
|
||||
var autoCloseTags = map[string]map[string]bool{
|
||||
"li": {"li": true},
|
||||
"tr": {"tr": true},
|
||||
"td": {"td": true, "th": true, "tr": true, "tbody": true, "tfoot": true},
|
||||
"th": {"th": true, "td": true, "tr": true, "tbody": true, "tfoot": true},
|
||||
"tbody": {"tbody": true, "tfoot": true},
|
||||
"thead": {"tbody": true, "tfoot": true},
|
||||
"column": {"column": true},
|
||||
}
|
||||
|
||||
var requiredAncestorTags = map[string]map[string]bool{
|
||||
"column": {"grid": true},
|
||||
"thead": {"table": true},
|
||||
"tbody": {"table": true},
|
||||
"tfoot": {"table": true},
|
||||
"tr": {"table": true, "thead": true, "tbody": true, "tfoot": true},
|
||||
"th": {"tr": true},
|
||||
"td": {"tr": true},
|
||||
"colgroup": {"table": true},
|
||||
"col": {"table": true, "colgroup": true},
|
||||
"okr-objective": {"okr": true},
|
||||
"okr-key-result": {"okr": true, "okr-objective": true},
|
||||
"okr-progress": {"okr-objective": true, "okr-key-result": true},
|
||||
"sub-page": {"sub-page-list": true},
|
||||
}
|
||||
|
||||
func shouldAutoClose(openTag, nextTag string) bool {
|
||||
if strictPhrasingTags[openTag] && layoutOf(nextTag) == layoutBlock {
|
||||
return true
|
||||
}
|
||||
return autoCloseTags[openTag] != nil && autoCloseTags[openTag][nextTag]
|
||||
}
|
||||
|
||||
func normalizeAttributes(rawTag, canonical string, attrs map[string]string) map[string]string {
|
||||
rules := make(map[string]attributeAliasRule, len(commonAttributeAliases)+4)
|
||||
for alias, rule := range commonAttributeAliases {
|
||||
rules[alias] = rule
|
||||
}
|
||||
for alias, rule := range tagAttributeAliases[canonical] {
|
||||
rules[alias] = rule
|
||||
}
|
||||
rawKey := strings.ToLower(strings.TrimSpace(rawTag))
|
||||
for alias, rule := range rawTagAttributeAliases[rawKey] {
|
||||
rules[alias] = rule
|
||||
}
|
||||
|
||||
aliases := make([]string, 0, len(rules))
|
||||
for alias := range rules {
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
sort.Strings(aliases)
|
||||
for _, alias := range aliases {
|
||||
value, exists := attrs[alias]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
rule := rules[alias]
|
||||
if rule.transform != nil {
|
||||
var ok bool
|
||||
value, ok = rule.transform(value)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if canonicalValue, exists := attrs[rule.canonical]; !exists || strings.TrimSpace(canonicalValue) == "" {
|
||||
if attrs == nil {
|
||||
attrs = map[string]string{}
|
||||
}
|
||||
attrs[rule.canonical] = value
|
||||
}
|
||||
delete(attrs, alias)
|
||||
}
|
||||
|
||||
if rawKey == "at" {
|
||||
if attrs == nil {
|
||||
attrs = map[string]string{}
|
||||
}
|
||||
attrs["type"] = "user"
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func normalizeWidthRatio(value string) (string, bool) {
|
||||
trimmed := strings.TrimSuffix(strings.TrimSpace(value), "%")
|
||||
if trimmed == "" {
|
||||
return value, false
|
||||
}
|
||||
width, err := strconv.ParseFloat(trimmed, 64)
|
||||
if err != nil {
|
||||
return value, false
|
||||
}
|
||||
return strconv.FormatFloat(width/100, 'f', 6, 64), true
|
||||
}
|
||||
|
||||
func requireChatID(value string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
return trimmed, strings.HasPrefix(trimmed, "oc_")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user