mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
27 Commits
codex/fix-
...
feat/chart
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e450090ef6 | ||
|
|
11205d2f8a | ||
|
|
e665fa335c | ||
|
|
f79908483d | ||
|
|
52b5910fb1 | ||
|
|
0b59556207 | ||
|
|
91743bba99 | ||
|
|
0f93cadc4b | ||
|
|
2459a2bb20 | ||
|
|
ee71d92d5f | ||
|
|
f32448536d | ||
|
|
62a32439f3 | ||
|
|
78c9653e8e | ||
|
|
28d2bdc7a9 | ||
|
|
69a9210a85 | ||
|
|
b9046f9ce9 | ||
|
|
2dd4419d3b | ||
|
|
84c6d23e79 | ||
|
|
6e20fd6e93 | ||
|
|
ca7135f582 | ||
|
|
7b58ba1b1d | ||
|
|
765b097d44 | ||
|
|
4a5e2c519a | ||
|
|
67fc870582 | ||
|
|
af8e027269 | ||
|
|
2efadec335 | ||
|
|
5fb70d326a |
@@ -10,10 +10,9 @@
|
||||
## 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 live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
|
||||
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 test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Notification Opt-Outs
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -2,62 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.74] - 2026-07-21
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: add history rollback shortcuts (#1714)
|
||||
- **base**: support per-record batch updates (#1889)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- preserve slides schema issues
|
||||
- allow jq examples in quality gate dry-runs
|
||||
- **im**: warn when flag pagination is truncated (#1906)
|
||||
- **slides**: warn on text shape overflow
|
||||
- **slides**: exempt chart roundtrip attributes from lint
|
||||
- **slides**: detect image text occlusion
|
||||
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
|
||||
|
||||
### Documentation
|
||||
|
||||
- clarify drive upload overwrite guidance (#1982)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate unit tests from user state (#1883)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- converge success output through a single Emitter that owns the write (#1899)
|
||||
|
||||
## [v1.0.73] - 2026-07-20
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: detect visual elements outside canvas
|
||||
- reduce public content credential fixture false positives
|
||||
- standardize CLI shortcut text in English (#1942)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: reduce filter and update retry loops (#1879)
|
||||
- **vc**: default transcript routing to smart notes over minutes (#1961)
|
||||
- clarify local trigger automation (#1958)
|
||||
|
||||
### Tests
|
||||
|
||||
- synchronize temporary Git maintenance (#1946)
|
||||
|
||||
### Misc
|
||||
|
||||
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
|
||||
- [codex] support bot menu events (#1765)
|
||||
|
||||
## [v1.0.72] - 2026-07-17
|
||||
|
||||
### Features
|
||||
@@ -1608,8 +1552,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
||||
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
|
||||
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
|
||||
|
||||
7
Makefile
7
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 live-skills-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 integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
|
||||
all: test
|
||||
|
||||
@@ -58,11 +58,6 @@ 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,18 +344,20 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
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.
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
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
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
// 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,9 +352,6 @@ 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,
|
||||
})
|
||||
@@ -374,33 +371,8 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "binary response detected") {
|
||||
t.Error("expected binary response hint in stderr")
|
||||
}
|
||||
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")
|
||||
if !strings.Contains(stdout.String(), "saved_path") {
|
||||
t.Error("expected saved_path in output")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -371,11 +371,10 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--as", "user", "--dry-run",
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -707,18 +707,20 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
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.
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
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
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// 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,7 +5,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -13,34 +12,11 @@ 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)
|
||||
@@ -78,7 +54,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 isStartupBrandHelper() {
|
||||
if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
|
||||
// Helper: replicate Execute()'s build wiring with a lark config.
|
||||
buildInternal(
|
||||
context.Background(), cmdutil.InvocationContext{},
|
||||
@@ -95,11 +71,9 @@ 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
|
||||
)
|
||||
@@ -111,33 +85,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// 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,8 +24,6 @@ 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()
|
||||
@@ -33,17 +31,13 @@ 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
|
||||
// and fully mocked skills operations. Tests that only care about install-method
|
||||
// detection must never fall through to the real npx skills CLI.
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
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 })
|
||||
@@ -110,18 +104,6 @@ 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)
|
||||
@@ -246,9 +228,6 @@ 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)
|
||||
@@ -277,9 +256,6 @@ 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)
|
||||
@@ -305,7 +281,6 @@ 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"})
|
||||
@@ -337,7 +312,6 @@ 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{})
|
||||
@@ -1187,7 +1161,6 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1204,10 +1177,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: successfulSkillsCommand(),
|
||||
}
|
||||
updater := &selfupdate.Updater{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)
|
||||
@@ -1227,7 +1197,6 @@ 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")
|
||||
@@ -1544,133 +1513,28 @@ func TestEmitSkillsTextHints_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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"),
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
// 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.Fatalf("live skills tests opted in but npx not found in PATH: %v", err)
|
||||
t.Skipf("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)
|
||||
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.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)
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("real global skills CLI unavailable: %v", err)
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
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)
|
||||
t.Skipf("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
|
||||
@@ -1766,17 +1630,26 @@ 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 and only
|
||||
// runs with explicit opt-in. All user directories are redirected to a temporary
|
||||
// home.
|
||||
// This is a live integration test that calls the real npx skills CLI; it is
|
||||
// skipped when npx or the skills registry is unavailable.
|
||||
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
|
||||
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 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)
|
||||
}
|
||||
|
||||
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// BotMenuOutput is the flattened shape for application.bot.menu_v6.
|
||||
type BotMenuOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always application.bot.menu_v6"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
AppID string `json:"app_id,omitempty" desc:"Application ID from the event header"`
|
||||
TenantKey string `json:"tenant_key,omitempty" desc:"Tenant key from the event header"`
|
||||
EventKey string `json:"event_key,omitempty" desc:"Developer-defined bot menu event key"`
|
||||
MenuTimestamp string `json:"menu_timestamp,omitempty" desc:"Menu click timestamp from the event body" kind:"timestamp_ms"`
|
||||
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id; kept as a short alias of operator_open_id" kind:"open_id"`
|
||||
OperatorOpenID string `json:"operator_open_id,omitempty" desc:"Operator open_id" kind:"open_id"`
|
||||
OperatorUnionID string `json:"operator_union_id,omitempty" desc:"Operator union_id" kind:"union_id"`
|
||||
OperatorUserID string `json:"operator_user_id,omitempty" desc:"Operator user_id" kind:"user_id"`
|
||||
OperatorName string `json:"operator_name,omitempty" desc:"Operator display name"`
|
||||
}
|
||||
|
||||
func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
AppID string `json:"app_id"`
|
||||
TenantKey string `json:"tenant_key"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
EventKey string `json:"event_key"`
|
||||
Timestamp json.RawMessage `json:"timestamp"`
|
||||
Operator struct {
|
||||
OperatorID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
UnionID string `json:"union_id"`
|
||||
UserID string `json:"user_id"`
|
||||
} `json:"operator_id"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
} `json:"operator"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
|
||||
timestamp := envelope.Header.CreateTime
|
||||
if timestamp == "" {
|
||||
timestamp = menuTimestamp
|
||||
}
|
||||
operatorID := envelope.Event.Operator.OperatorID.OpenID
|
||||
|
||||
out := &BotMenuOutput{
|
||||
Type: eventTypeBotMenuV6,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: timestamp,
|
||||
AppID: envelope.Header.AppID,
|
||||
TenantKey: envelope.Header.TenantKey,
|
||||
EventKey: envelope.Event.EventKey,
|
||||
MenuTimestamp: menuTimestamp,
|
||||
OperatorID: operatorID,
|
||||
OperatorOpenID: operatorID,
|
||||
OperatorUnionID: envelope.Event.Operator.OperatorID.UnionID,
|
||||
OperatorUserID: envelope.Event.Operator.OperatorID.UserID,
|
||||
OperatorName: envelope.Event.Operator.OperatorName,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func rawScalarString(raw json.RawMessage) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return ""
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
return text
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func timestampMillisString(raw json.RawMessage) string {
|
||||
s := rawScalarString(raw)
|
||||
if len(s) == 10 && allDigits(s) {
|
||||
return s + "000"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func allDigits(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestKeysBotMenuMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
|
||||
}
|
||||
|
||||
def := keys[0]
|
||||
if def.Key != eventTypeBotMenuV6 {
|
||||
t.Errorf("Key = %q, want %q", def.Key, eventTypeBotMenuV6)
|
||||
}
|
||||
if def.EventType != eventTypeBotMenuV6 {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeBotMenuV6)
|
||||
}
|
||||
if def.SubscriptionType != "" {
|
||||
t.Errorf("SubscriptionType = %q, want default event subscription", def.SubscriptionType)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Fatal("Schema.Custom is nil")
|
||||
}
|
||||
if def.Schema.Custom.Type != reflect.TypeOf(BotMenuOutput{}) {
|
||||
t.Errorf("custom type = %v, want BotMenuOutput", def.Schema.Custom.Type)
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Fatal("Schema.Native must be nil for processed output")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Fatal("Process is nil")
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
|
||||
t.Errorf("AuthTypes = %#v", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeBotMenuV6}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotMenuRegistersCleanly(t *testing.T) {
|
||||
const key = eventTypeBotMenuV6
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenu(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_001",
|
||||
"event_type": "application.bot.menu_v6",
|
||||
"create_time": "1776409469273",
|
||||
"app_id": "cli_test",
|
||||
"tenant_key": "tenant_test"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": 1776409469000,
|
||||
"operator": {
|
||||
"operator_id": {
|
||||
"open_id": "ou_operator",
|
||||
"union_id": "on_operator",
|
||||
"user_id": "user_operator"
|
||||
},
|
||||
"operator_name": "Test User"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Type != eventTypeBotMenuV6 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
|
||||
}
|
||||
if out.EventID != "ev_menu_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
if out.EventKey != "start_eval" {
|
||||
t.Errorf("EventKey = %q", out.EventKey)
|
||||
}
|
||||
if out.MenuTimestamp != "1776409469000" {
|
||||
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
|
||||
}
|
||||
if out.OperatorID != "ou_operator" || out.OperatorOpenID != "ou_operator" {
|
||||
t.Errorf("OperatorID/OperatorOpenID = %q/%q", out.OperatorID, out.OperatorOpenID)
|
||||
}
|
||||
if out.OperatorUnionID != "on_operator" {
|
||||
t.Errorf("OperatorUnionID = %q", out.OperatorUnionID)
|
||||
}
|
||||
if out.OperatorUserID != "user_operator" {
|
||||
t.Errorf("OperatorUserID = %q", out.OperatorUserID)
|
||||
}
|
||||
if out.OperatorName != "Test User" {
|
||||
t.Errorf("OperatorName = %q", out.OperatorName)
|
||||
}
|
||||
if out.AppID != "cli_test" || out.TenantKey != "tenant_test" {
|
||||
t.Errorf("AppID/TenantKey = %q/%q", out.AppID, out.TenantKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuStringTimestampFallback(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_002",
|
||||
"event_type": "application.bot.menu_v6"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": "1776409469001",
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Timestamp != "1776409469001" {
|
||||
t.Errorf("Timestamp fallback = %q", out.Timestamp)
|
||||
}
|
||||
if out.MenuTimestamp != "1776409469001" {
|
||||
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuSecondsTimestampFallback(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_seconds",
|
||||
"event_type": "application.bot.menu_v6"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": 1694592375,
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Timestamp != "1694592375000" {
|
||||
t.Errorf("Timestamp fallback = %q, want seconds normalized to milliseconds", out.Timestamp)
|
||||
}
|
||||
if out.MenuTimestamp != "1694592375000" {
|
||||
t.Errorf("MenuTimestamp = %q, want seconds normalized to milliseconds", out.MenuTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuTypeUsesLocalConstant(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_003",
|
||||
"event_type": "unexpected.event_type",
|
||||
"create_time": "1776409469275"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Type != eventTypeBotMenuV6 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuMalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processBotMenu(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func runBotMenu(t *testing.T, payload string) BotMenuOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processBotMenu(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("processBotMenu: %v", err)
|
||||
}
|
||||
var out BotMenuOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\n%s", err, got)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package application registers Application-domain EventKeys.
|
||||
package application
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const eventTypeBotMenuV6 = "application.bot.menu_v6"
|
||||
|
||||
// Keys returns all Application-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeBotMenuV6,
|
||||
DisplayName: "Bot menu",
|
||||
Description: "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(BotMenuOutput{})},
|
||||
},
|
||||
Process: processBotMenu,
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeBotMenuV6},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/application"
|
||||
"github.com/larksuite/cli/events/approval"
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
application.Keys(),
|
||||
approval.Keys(),
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// 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,14 +132,16 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
})
|
||||
}
|
||||
|
||||
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()})
|
||||
// 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
|
||||
}
|
||||
|
||||
// Non-JSON (binary) responses.
|
||||
|
||||
@@ -18,7 +18,6 @@ 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"
|
||||
)
|
||||
@@ -240,87 +239,6 @@ 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"})
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
@@ -14,12 +18,75 @@ import (
|
||||
// with --yes.
|
||||
//
|
||||
// action identifies the operation for the agent (e.g. "mail +send",
|
||||
// "drive.files.delete"). The envelope does not carry a pre-built retry
|
||||
// command: agents already know their original invocation and only need to
|
||||
// append --yes per the hint, which keeps the protocol free of shell-quoting
|
||||
// pitfalls.
|
||||
// "drive.files.delete"). When the original invocation can be re-run safely,
|
||||
// the hint carries the complete retry command with --yes appended — eval
|
||||
// traces show agents always self-heal by appending --yes, so handing them
|
||||
// the exact line saves the reconstruction step. The retry line is omitted
|
||||
// (falling back to the plain hint) when any argument reads stdin (a bare "-",
|
||||
// as its own token or bundled onto a flag as --flag=-, whose piped data a
|
||||
// bare re-run would not reproduce) or when the rendered command would be
|
||||
// unreasonably long to echo back.
|
||||
func RequireConfirmation(action string) error {
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action).
|
||||
WithHint("add --yes to confirm")
|
||||
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action)
|
||||
if retry := retryCommandWithYes(os.Args); retry != "" {
|
||||
return err.WithHint("add --yes to confirm; re-run: %s", retry)
|
||||
}
|
||||
return err.WithHint("add --yes to confirm")
|
||||
}
|
||||
|
||||
// retryCommandMaxLen caps the rendered retry command: past this, echoing the
|
||||
// full invocation back (e.g. a +batch-update with a large inline JSON)
|
||||
// costs more context than it saves.
|
||||
const retryCommandMaxLen = 300
|
||||
|
||||
// retryCommandWithYes renders args as a shell-safe command line with --yes
|
||||
// appended, or "" when a safe rendering isn't possible (see
|
||||
// RequireConfirmation).
|
||||
func retryCommandWithYes(args []string) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(args)+1)
|
||||
parts = append(parts, filepath.Base(args[0]))
|
||||
for _, a := range args[1:] {
|
||||
if argReadsStdin(a) {
|
||||
return ""
|
||||
}
|
||||
parts = append(parts, shellQuoteArg(a))
|
||||
}
|
||||
parts = append(parts, "--yes")
|
||||
line := strings.Join(parts, " ")
|
||||
if len(line) > retryCommandMaxLen {
|
||||
return ""
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// argReadsStdin reports whether an argument makes a flag read from stdin — the
|
||||
// portable bare "-" value, whether passed as its own token (--flag -) or
|
||||
// bundled onto the flag (--flag=- / -f=-). Piped stdin is one-shot data a bare
|
||||
// re-run cannot reproduce, so any such argument suppresses the retry line.
|
||||
func argReadsStdin(a string) bool {
|
||||
if a == "-" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(a, "-") {
|
||||
if i := strings.IndexByte(a, '='); i >= 0 && a[i+1:] == "-" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shellQuoteArg single-quotes an argument when it contains any character a
|
||||
// POSIX shell could interpret, so the retry line is copy-paste safe.
|
||||
func shellQuoteArg(s string) string {
|
||||
if s == "" {
|
||||
return "''"
|
||||
}
|
||||
if !strings.ContainsAny(s, " \t\n\"'\\$`!*?[](){}<>|&;#~") {
|
||||
return s
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
@@ -35,8 +35,11 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
|
||||
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
|
||||
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
|
||||
}
|
||||
if cre.Hint != "add --yes to confirm" {
|
||||
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
|
||||
// The hint may additionally carry a re-run line composed from the live
|
||||
// os.Args (environment-dependent under `go test`), but the add-yes
|
||||
// contract always leads.
|
||||
if !strings.HasPrefix(cre.Hint, "add --yes to confirm") {
|
||||
t.Errorf("Hint = %q, want prefix 'add --yes to confirm'", cre.Hint)
|
||||
}
|
||||
if cre.Risk != errs.RiskHighRiskWrite {
|
||||
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
|
||||
@@ -61,8 +64,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// No fix_command field leaks into the envelope: the protocol avoids
|
||||
// shell-quoting hazards by delegating retry to agent-side logic.
|
||||
// No fix_command field leaks into the envelope: the retry line lives in
|
||||
// the free-text hint only; the typed protocol stays action-only.
|
||||
if _, has := back["fix_command"]; has {
|
||||
t.Errorf("unexpected fix_command present in JSON: %s", raw)
|
||||
}
|
||||
@@ -78,3 +81,46 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryCommandWithYes pins the retry-line contract: shell-safe quoting,
|
||||
// basename argv[0], and the two omission guards (stdin args, oversized
|
||||
// commands).
|
||||
func TestRetryCommandWithYes(t *testing.T) {
|
||||
t.Run("quotes what needs quoting and appends --yes", func(t *testing.T) {
|
||||
got := retryCommandWithYes([]string{
|
||||
"/usr/local/bin/lark-cli", "sheets", "+cells-clear",
|
||||
"--url", "https://x.feishu.cn/sheets/tok",
|
||||
"--range", "A1:B2", "--sheet-name", "第 1 班",
|
||||
})
|
||||
want := `lark-cli sheets +cells-clear --url https://x.feishu.cn/sheets/tok --range A1:B2 --sheet-name '第 1 班' --yes`
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single quotes inside args survive", func(t *testing.T) {
|
||||
got := retryCommandWithYes([]string{"lark-cli", "x", "--title", "it's"})
|
||||
if !strings.Contains(got, `'it'\''s'`) {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stdin arg omits the retry line", func(t *testing.T) {
|
||||
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+batch-update", "--operations", "-"}); got != "" {
|
||||
t.Errorf("stdin invocation must not render a retry line, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bundled stdin flag omits the retry line", func(t *testing.T) {
|
||||
// --flag=- reads stdin the same as --flag -; both must suppress the line.
|
||||
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+cells-set", "--cells=-"}); got != "" {
|
||||
t.Errorf("--flag=- stdin invocation must not render a retry line, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized command omits the retry line", func(t *testing.T) {
|
||||
if got := retryCommandWithYes([]string{"lark-cli", "x", "--operations", strings.Repeat("a", 400)}); got != "" {
|
||||
t.Errorf("oversized invocation must not render a retry line, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// ResolveInput resolves special input conventions for a raw flag value:
|
||||
@@ -77,7 +80,25 @@ func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, er
|
||||
|
||||
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
|
||||
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
|
||||
// An absolute path under the system temp dir is read directly instead:
|
||||
// agents stage generated payloads (@/tmp/ops.json) there as a matter of
|
||||
// course, and the strict relative-to-cwd policy — load-bearing for uploads
|
||||
// and drive sync — only cost @file callers a python/stdin detour.
|
||||
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
|
||||
resolved, terr := validate.SafeTempAbsInputPath(path)
|
||||
if terr == nil {
|
||||
data, err := os.ReadFile(resolved) //nolint:forbidigo // resolved is confined to the system temp dir by SafeTempAbsInputPath
|
||||
if err != nil {
|
||||
return nil, wrapInputFileError(path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
// Absolute but outside the temp dir: surface the prescriptive error
|
||||
// (relative path / temp-dir path / stdin) instead of the generic
|
||||
// relative-only message the strict validator below would produce.
|
||||
return nil, fmt.Errorf("invalid file path %q: %w", path, terr)
|
||||
}
|
||||
if fileIO == nil {
|
||||
return nil, fmt.Errorf("file input is not available in this context")
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// 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,91 +7,70 @@ 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{}) {
|
||||
// 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(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 {
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
} else {
|
||||
return WriteJSON(w, data)
|
||||
PrintJson(w, data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if isFirstPage {
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
}
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
if !isList {
|
||||
// Single object: key,value rows
|
||||
cw := csv.NewWriter(w)
|
||||
if isFirstPage {
|
||||
if err := cw.Write([]string{"key", "value"}); err != nil {
|
||||
return err
|
||||
}
|
||||
cw.Write([]string{"key", "value"})
|
||||
}
|
||||
for _, col := range cols {
|
||||
if err := cw.Write([]string{col, rows[0][col]}); err != nil {
|
||||
return err
|
||||
}
|
||||
cw.Write([]string{col, rows[0][col]})
|
||||
}
|
||||
return flushCSV(cw)
|
||||
flushCSV(cw)
|
||||
return
|
||||
}
|
||||
|
||||
return writeCSVRows(w, rows, cols, isFirstPage)
|
||||
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) error {
|
||||
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) {
|
||||
cw := csv.NewWriter(w)
|
||||
if writeHeader {
|
||||
if err := cw.Write(cols); err != nil {
|
||||
return err
|
||||
}
|
||||
cw.Write(cols)
|
||||
}
|
||||
for _, row := range rows {
|
||||
record := make([]string, len(cols))
|
||||
for i, col := range cols {
|
||||
record[i] = row[col]
|
||||
}
|
||||
if err := cw.Write(record); err != nil {
|
||||
return err
|
||||
}
|
||||
cw.Write(record)
|
||||
}
|
||||
return flushCSV(cw)
|
||||
flushCSV(cw)
|
||||
}
|
||||
|
||||
// flushCSV flushes the csv.Writer and returns any write error.
|
||||
func flushCSV(cw *csv.Writer) error {
|
||||
// flushCSV flushes the csv.Writer and reports any write error to stderr.
|
||||
func flushCSV(cw *csv.Writer) {
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
if err := cw.Error(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "csv write error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +50,10 @@ 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) error {
|
||||
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) {
|
||||
if alert == nil {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
|
||||
fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
|
||||
alert.Provider, strings.Join(alert.MatchedRules, ", "))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
@@ -1,827 +0,0 @@
|
||||
// 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,17 +34,27 @@ 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 {
|
||||
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,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -101,44 +101,34 @@ 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 {
|
||||
return WriteNDJSON(w, items)
|
||||
PrintNdjson(w, items)
|
||||
} else {
|
||||
PrintNdjson(w, data)
|
||||
}
|
||||
return WriteNDJSON(w, data)
|
||||
|
||||
case FormatTable:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
return WriteTable(w, items)
|
||||
FormatAsTable(w, items)
|
||||
} else {
|
||||
FormatAsTable(w, data)
|
||||
}
|
||||
return WriteTable(w, data)
|
||||
|
||||
case FormatCSV:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
return WriteCSV(w, items)
|
||||
FormatAsCSV(w, items)
|
||||
} else {
|
||||
FormatAsCSV(w, data)
|
||||
}
|
||||
return WriteCSV(w, data)
|
||||
|
||||
default: // FormatJSON
|
||||
return WriteJSON(w, data)
|
||||
PrintJson(w, data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,63 +148,49 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
|
||||
|
||||
// FormatPage formats one page of items.
|
||||
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
|
||||
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)
|
||||
PrintNdjson(pf.W, arr)
|
||||
} else {
|
||||
PrintNdjson(pf.W, data)
|
||||
}
|
||||
return WriteNDJSON(pf.W, data)
|
||||
|
||||
case FormatTable:
|
||||
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
|
||||
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
|
||||
widths := computeColumnWidths(rows, cols)
|
||||
if isFirst {
|
||||
if err := writeHeader(w, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
writeHeader(w, cols, widths)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := writeRow(w, row, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
writeRow(w, row, cols, widths)
|
||||
}
|
||||
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)
|
||||
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
|
||||
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) error) error {
|
||||
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool)) {
|
||||
rows, pageCols, isList := prepareRows(data)
|
||||
if len(rows) == 0 {
|
||||
if pf.isFirstPage && isList {
|
||||
_, err := fmt.Fprintln(pf.W, "(empty)")
|
||||
return err
|
||||
fmt.Fprintln(pf.W, "(empty)")
|
||||
}
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
if pf.isFirstPage {
|
||||
// Lock columns from first page
|
||||
pf.cols = pageCols
|
||||
pf.isFirstPage = false
|
||||
return emit(pf.W, rows, pf.cols, true)
|
||||
emit(pf.W, rows, pf.cols, true)
|
||||
} else {
|
||||
// Reuse first page's columns — missing keys become empty, extra keys ignored
|
||||
return emit(pf.W, rows, pf.cols, false)
|
||||
emit(pf.W, rows, pf.cols, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -16,44 +15,12 @@ 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 {
|
||||
return &outputMarshalError{err: err}
|
||||
fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err)
|
||||
return
|
||||
}
|
||||
_, err = fmt.Fprintln(w, string(b))
|
||||
return err
|
||||
fmt.Fprintln(w, string(b))
|
||||
}
|
||||
|
||||
// injectNotice adds a "_notice" field into CLI envelope maps.
|
||||
@@ -83,38 +50,21 @@ func injectNotice(data interface{}) {
|
||||
|
||||
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
|
||||
func PrintNdjson(w io.Writer, data 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 {
|
||||
emit := func(item interface{}) {
|
||||
b, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return &outputMarshalError{err: err}
|
||||
fmt.Fprintf(os.Stderr, "ndjson marshal error: %v\n", err)
|
||||
return
|
||||
}
|
||||
_, err = fmt.Fprintln(w, string(b))
|
||||
return err
|
||||
fmt.Fprintln(w, string(b))
|
||||
}
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if err := emit(item); err != nil {
|
||||
return err
|
||||
}
|
||||
emit(item)
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
emit(data)
|
||||
}
|
||||
return emit(data)
|
||||
}
|
||||
|
||||
func cellStr(val interface{}) string {
|
||||
|
||||
@@ -16,69 +16,50 @@ const maxColWidth = 100
|
||||
// - map[string]interface{} (single object) → key-value two-column table
|
||||
// - empty array → "(empty)"
|
||||
func FormatAsTable(w io.Writer, data interface{}) {
|
||||
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(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 {
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
} else {
|
||||
// Not a list and not an object — print as JSON fallback
|
||||
return WriteJSON(w, data)
|
||||
PrintJson(w, data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if isFirstPage {
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
}
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
if !isList {
|
||||
// Single object: key-value two-column format
|
||||
return formatKeyValueTable(w, rows[0], cols)
|
||||
formatKeyValueTable(w, rows[0], cols)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate column widths (clamped to maxColWidth)
|
||||
widths := computeColumnWidths(rows, cols)
|
||||
|
||||
if isFirstPage {
|
||||
if err := writeHeader(w, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
writeHeader(w, cols, widths)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if err := writeRow(w, row, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
writeRow(w, row, cols, widths)
|
||||
}
|
||||
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) error {
|
||||
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) {
|
||||
maxKeyWidth := 0
|
||||
for _, col := range cols {
|
||||
kw := stringWidth(col)
|
||||
@@ -90,11 +71,8 @@ func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) erro
|
||||
for _, col := range cols {
|
||||
val := row[col]
|
||||
val = truncateToWidth(val, maxColWidth)
|
||||
if _, err := fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeColumnWidths returns display widths for each column, clamped to maxColWidth.
|
||||
@@ -121,29 +99,25 @@ 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) error {
|
||||
func writeHeader(w io.Writer, cols []string, widths []int) {
|
||||
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]))
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, strings.Join(header, " ")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(w, strings.Join(sep, " "))
|
||||
return err
|
||||
fmt.Fprintln(w, strings.Join(header, " "))
|
||||
fmt.Fprintln(w, strings.Join(sep, " "))
|
||||
}
|
||||
|
||||
// writeRow writes a single data row.
|
||||
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) error {
|
||||
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) {
|
||||
var cells []string
|
||||
for i, col := range cols {
|
||||
val := truncateToWidth(row[col], widths[i])
|
||||
cells = append(cells, padToWidth(val, widths[i]))
|
||||
}
|
||||
_, err := fmt.Fprintln(w, strings.Join(cells, " "))
|
||||
return err
|
||||
fmt.Fprintln(w, strings.Join(cells, " "))
|
||||
}
|
||||
|
||||
// padToWidth pads a string with spaces to reach the target display width.
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,18 +19,12 @@ import (
|
||||
type eventPayload struct {
|
||||
Comment *struct {
|
||||
Body string `json:"body"`
|
||||
Path string `json:"path"`
|
||||
} `json:"comment"`
|
||||
Review *struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"review"`
|
||||
}
|
||||
|
||||
type commentContent struct {
|
||||
Body string
|
||||
Path string
|
||||
}
|
||||
|
||||
func main() {
|
||||
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
|
||||
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
|
||||
@@ -40,11 +34,12 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
diags, err := auditEvent(*eventPath, *kind)
|
||||
body, err := commentBody(*eventPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
diags := diagnostics(publiccontent.ScanComment(*kind, body))
|
||||
if len(diags) > 0 {
|
||||
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
|
||||
}
|
||||
@@ -52,44 +47,32 @@ func main() {
|
||||
os.Exit(report.ExitCode(diags))
|
||||
}
|
||||
|
||||
func auditEvent(eventPath, kind string) ([]report.Diagnostic, error) {
|
||||
content, err := commentBody(eventPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCommentContent(kind, content), nil
|
||||
}
|
||||
|
||||
func scanCommentContent(kind string, content commentContent) []report.Diagnostic {
|
||||
return diagnostics(publiccontent.ScanCommentAtPath(kind, content.Path, content.Body))
|
||||
}
|
||||
|
||||
func auditFailureSummary(count int) string {
|
||||
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
|
||||
}
|
||||
|
||||
func commentBody(path string) (commentContent, error) {
|
||||
func commentBody(path string) (string, error) {
|
||||
safePath, err := validate.SafeInputPath(path)
|
||||
if err != nil {
|
||||
return commentContent{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
WithParam("--event").
|
||||
WithCause(err)
|
||||
}
|
||||
data, err := vfs.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return commentContent{}, err
|
||||
return "", err
|
||||
}
|
||||
var payload eventPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return commentContent{}, err
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case payload.Comment != nil:
|
||||
return commentContent{Body: payload.Comment.Body, Path: payload.Comment.Path}, nil
|
||||
return payload.Comment.Body, nil
|
||||
case payload.Review != nil:
|
||||
return commentContent{Body: payload.Review.Body}, nil
|
||||
return payload.Review.Body, nil
|
||||
default:
|
||||
return commentContent{}, nil
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||
)
|
||||
|
||||
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
@@ -34,92 +32,11 @@ func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got.Body != "clean comment" || got.Path != "" {
|
||||
t.Fatalf("comment content = %#v", got)
|
||||
if got != "clean comment" {
|
||||
t.Fatalf("comment body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentBodyReadsReviewCommentPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"test suggestion","path":"cmd/agent/list_test.go"}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origDir)
|
||||
})
|
||||
|
||||
got, err := commentBody("event.json")
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got.Body != "test suggestion" || got.Path != "cmd/agent/list_test.go" {
|
||||
t.Fatalf("comment content = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentAuditUsesReviewCommentPathForFixtureClassification(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := `CLIENT_SECRET=$(security find-generic-password -w)`
|
||||
event := `{"comment":{"body":` + strconv.Quote(body) + `,"path":"scripts/config_test.sh"}}`
|
||||
if err := writeTestFile(filepath.Join(dir, "event.json"), event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origDir)
|
||||
})
|
||||
|
||||
diags, err := auditEvent("event.json", "pull_request_review_comment")
|
||||
if err != nil {
|
||||
t.Fatalf("auditEvent() error = %v", err)
|
||||
}
|
||||
for _, diag := range diags {
|
||||
if diag.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("review comment fixture should not be a credential diagnostic: %#v", diags)
|
||||
}
|
||||
}
|
||||
pathless := publiccontent.ScanComment("pull_request_review_comment", body)
|
||||
for _, finding := range pathless {
|
||||
if finding.Rule == "public_content_generic_credential" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("test precondition failed: pathless comment should be classified as a credential: %#v", pathless)
|
||||
}
|
||||
|
||||
func TestScanCommentContentPreservesReviewCommentPath(t *testing.T) {
|
||||
providerValue := "gh" + "p_" + "1234567890abcdef" + "1234567890abcdef" + "1234"
|
||||
content := commentContent{
|
||||
Body: `cfg := &Config{AccessToken: "` + providerValue + `"}`,
|
||||
Path: "cmd/agent/list_test.go",
|
||||
}
|
||||
|
||||
diags := scanCommentContent("pull_request_review_comment", content)
|
||||
for _, diag := range diags {
|
||||
if diag.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
if diag.File != content.Path {
|
||||
t.Fatalf("credential diagnostic file = %q, want %q", diag.File, content.Path)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("missing provider credential diagnostic: %#v", diags)
|
||||
}
|
||||
|
||||
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "event.json")
|
||||
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
|
||||
|
||||
@@ -6,11 +6,10 @@ package diff
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
)
|
||||
|
||||
func TestScopeIncludesChangedSkillAndRelatedDomain(t *testing.T) {
|
||||
@@ -123,7 +122,8 @@ func writeFile(t *testing.T, repo, rel, content string) {
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
}
|
||||
@@ -131,7 +131,8 @@ func runGit(t *testing.T, repo string, args ...string) {
|
||||
|
||||
func gitOutput(t *testing.T, repo string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v", args, err)
|
||||
|
||||
@@ -6,11 +6,10 @@ package publiccontent
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
)
|
||||
|
||||
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||
@@ -24,10 +23,9 @@ func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||
runGit(t, repo, "add", "baseline.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
|
||||
|
||||
api_`+`key = "`+providerValue+`"
|
||||
api_`+`key = "example-public-key"
|
||||
`)
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
|
||||
@@ -201,14 +199,13 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"access_` + `token":"` + providerValue + `"}`,
|
||||
`{"client_` + `secret": "` + providerValue + `"}`,
|
||||
`{"tenantAccess` + `Token":"` + providerValue + `"}`,
|
||||
`{"github` + `Token":"` + providerValue + `"}`,
|
||||
`{"vendorApi` + `Key":"` + providerValue + `"}`,
|
||||
`{"slackBot` + `Token":"xoxb_` + `1234567890abcdef"}`,
|
||||
`{"access_` + `token":"real-json-token"}`,
|
||||
`{"client_` + `secret": "real ` + `secret value"}`,
|
||||
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
|
||||
`{"github` + `Token":"real-github-token"}`,
|
||||
`{"vendorApi` + `Key":"real-vendor-key"}`,
|
||||
`{"slackBot` + `Token":"xoxb-real-token"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add json config")
|
||||
@@ -218,7 +215,14 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
for _, forbidden := range []string{providerValue, "xoxb_" + "1234567890abcdef"} {
|
||||
for _, forbidden := range []string{
|
||||
"real-json-token",
|
||||
"real secret value",
|
||||
"real-tenant-camel-token",
|
||||
"real-github-token",
|
||||
"real-vendor-key",
|
||||
"xoxb-real-token",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -302,8 +306,8 @@ func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("angle-wrapped provider credential findings = %d, want 2: %#v", count, got)
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,12 +338,12 @@ func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("provider-shaped benign-key findings = %d, want 4: %#v", count, got)
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -354,11 +358,15 @@ func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
@@ -366,7 +374,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
@@ -383,7 +391,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.Contains(item.Excerpt, accessKey) {
|
||||
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
|
||||
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
|
||||
}
|
||||
}
|
||||
@@ -424,7 +432,7 @@ func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -440,11 +448,15 @@ func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T)
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
|
||||
@@ -477,13 +489,12 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_OPENAI: " + providerValue,
|
||||
"TOKEN_GITHUB: " + providerValue,
|
||||
"CLIENT_SECRET_GOOGLE: " + providerValue,
|
||||
"SECRET_KEY_BASE: " + providerValue,
|
||||
"APP_PASSWORD_PROD: " + providerValue,
|
||||
"API_KEY_OPENAI: real-openai-key",
|
||||
"TOKEN_GITHUB: real-github-token",
|
||||
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||
"SECRET_KEY_BASE: real-secret-key-base",
|
||||
"APP_PASSWORD_PROD: real-prod-password",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
@@ -495,7 +506,13 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{providerValue} {
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -604,8 +621,7 @@ func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add scanner fixtures")
|
||||
|
||||
@@ -669,11 +685,10 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
|
||||
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
|
||||
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add special paths")
|
||||
|
||||
@@ -840,7 +855,8 @@ func runGit(t *testing.T, repo string, args ...string) {
|
||||
if len(args) > 0 && args[0] == "commit" {
|
||||
args = append([]string{"commit", "--no-verify"}, args[1:]...)
|
||||
}
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
@@ -849,7 +865,8 @@ func runGit(t *testing.T, repo string, args ...string) {
|
||||
|
||||
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
|
||||
t.Helper()
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
|
||||
@@ -4,15 +4,8 @@
|
||||
package publiccontent
|
||||
|
||||
func ScanComment(kind, body string) []Finding {
|
||||
return ScanCommentAtPath(kind, "", body)
|
||||
}
|
||||
|
||||
func ScanCommentAtPath(kind, path, body string) []Finding {
|
||||
if kind == "" {
|
||||
kind = "comment"
|
||||
}
|
||||
if path == "" {
|
||||
path = kind
|
||||
}
|
||||
return scanText(path, "comment", body, isDetectorRuleFile(path))
|
||||
return scanText(kind, "comment", body, false)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
|
||||
@@ -20,60 +17,3 @@ func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAllowsMermaidCredentialTerminology(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"```mermaid",
|
||||
"sequenceDiagram",
|
||||
" participant Client",
|
||||
" participant AccessTokenHashTransport",
|
||||
" participant SecurityPolicyTransport",
|
||||
" Client->>AccessTokenHashTransport: Send request with bearer token",
|
||||
" AccessTokenHashTransport->>AccessTokenHashTransport: Clone request and inject token hash",
|
||||
" Client -> ClientSecret: Resolve configured credential",
|
||||
" AccessTokenHashTransport->>SecurityPolicyTransport: Forward enriched request",
|
||||
"```",
|
||||
}, "\n")
|
||||
|
||||
got := ScanComment("issue_comment", body)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("mermaid credential terminology should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentDetectsCredentialAssignmentInsideMermaidMessage(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
credentialAssignment := "password=" + providerValue
|
||||
body := strings.Join([]string{
|
||||
"```mermaid",
|
||||
"sequenceDiagram",
|
||||
" Client->>Server: Send " + credentialAssignment,
|
||||
"```",
|
||||
}, "\n")
|
||||
|
||||
got := ScanComment("issue_comment", body)
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("credential assignment inside mermaid message should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAtPathAllowsTestFixtureCredentialPlaceholder(t *testing.T) {
|
||||
body := `cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret"}`
|
||||
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("review comment test fixture should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAtPathDetectsProviderCredentialInTestFile(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
body := `cfg := &Config{AccessToken: "` + providerValue + `"}`
|
||||
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("provider credential in review comment should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func credentialValueHasStrongEvidence(key, value string) bool {
|
||||
normalized := strings.TrimRight(strings.TrimSpace(value), ",;")
|
||||
normalized = strings.TrimSpace(strings.Trim(normalized, `"'<>`))
|
||||
candidates := credentialEvidenceCandidates(unwrapCredentialValue(normalized))
|
||||
for _, candidate := range candidates {
|
||||
if providerCredentialIdentifier(candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if isCredentialMetadataField(key) {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if highEntropyCredentialValue(strings.ToLower(candidate)) || base64PaddedCredentialValue(candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return percentEncodedCredentialValue(strings.ToLower(candidates[0])) ||
|
||||
commandSubstitutionLooksCredentialLike(strings.ToLower(normalized))
|
||||
}
|
||||
|
||||
func credentialEvidenceCandidates(value string) []string {
|
||||
candidates := []string{value}
|
||||
for range 3 {
|
||||
decoded, err := url.PathUnescape(value)
|
||||
if err != nil || decoded == value {
|
||||
break
|
||||
}
|
||||
candidates = append(candidates, decoded)
|
||||
value = decoded
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func isCredentialMetadataField(key string) bool {
|
||||
if isBenignTokenField(key) {
|
||||
return true
|
||||
}
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
switch parts[len(parts)-1] {
|
||||
case "hash", "id", "kind", "marker", "prefix", "transport":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func base64PaddedCredentialValue(value string) bool {
|
||||
if len(value) < 16 || !strings.HasSuffix(value, "=") {
|
||||
return false
|
||||
}
|
||||
if _, err := base64.StdEncoding.DecodeString(value); err != nil {
|
||||
return false
|
||||
}
|
||||
return shannonEntropy(strings.TrimRight(value, "=")) >= 3.5
|
||||
}
|
||||
|
||||
func percentEncodedCredentialValue(value string) bool {
|
||||
if len(value) < 16 {
|
||||
return false
|
||||
}
|
||||
var escapes int
|
||||
for i := 0; i+2 < len(value); i++ {
|
||||
if value[i] == '%' && isHexByte(value[i+1]) && isHexByte(value[i+2]) {
|
||||
escapes++
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
return escapes >= 2
|
||||
}
|
||||
|
||||
func isHexByte(value byte) bool {
|
||||
return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f')
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*(?::=|[:=])\s*(?:!!str\s+)?(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\x60[^\x60]*\x60)|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\x60\s,}\]]+))`)
|
||||
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
|
||||
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
|
||||
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
|
||||
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
|
||||
@@ -383,63 +383,33 @@ func anglePlaceholderIdentifier(value string) bool {
|
||||
}
|
||||
|
||||
func credentialShapedValue(value string) bool {
|
||||
normalized := strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'<>`))
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
return credentialShapedIdentifier(normalized)
|
||||
}
|
||||
|
||||
func credentialShapedIdentifier(value string) bool {
|
||||
return providerCredentialIdentifier(value)
|
||||
}
|
||||
|
||||
func providerCredentialIdentifier(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
switch {
|
||||
case providerTokenWithBody(value, "sk_live_", 16, ""),
|
||||
providerTokenWithBody(value, "sk_test_", 16, ""),
|
||||
providerTokenWithBody(value, "ghp_", 16, ""),
|
||||
providerTokenWithBody(value, "gho_", 16, ""),
|
||||
providerTokenWithBody(value, "ghu_", 16, ""),
|
||||
providerTokenWithBody(value, "github_pat_", 16, "_"),
|
||||
providerTokenWithBody(value, "xoxb_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxp_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxa_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxb-", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxp-", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxa-", 16, "-"),
|
||||
awsAccessKeyIdentifier(value):
|
||||
case strings.HasPrefix(value, "sk_live_"),
|
||||
strings.HasPrefix(value, "sk_test_"),
|
||||
strings.HasPrefix(value, "ghp_"),
|
||||
strings.HasPrefix(value, "gho_"),
|
||||
strings.HasPrefix(value, "ghu_"),
|
||||
strings.HasPrefix(value, "github_pat_"),
|
||||
strings.HasPrefix(value, "xoxb_"),
|
||||
strings.HasPrefix(value, "xoxp_"),
|
||||
strings.HasPrefix(value, "xoxa_"):
|
||||
return true
|
||||
case strings.HasPrefix(value, "real-") &&
|
||||
(strings.Contains(value, "secret") ||
|
||||
strings.Contains(value, "token") ||
|
||||
strings.Contains(value, "key") ||
|
||||
strings.Contains(value, "password")):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func providerTokenWithBody(value, prefix string, minBodyLength int, separators string) bool {
|
||||
body, ok := strings.CutPrefix(value, prefix)
|
||||
if !ok || len(body) < minBodyLength {
|
||||
return false
|
||||
}
|
||||
for _, r := range body {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune(separators, r) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func awsAccessKeyIdentifier(value string) bool {
|
||||
if len(value) != 20 || (!strings.HasPrefix(value, "AKIA") && !strings.HasPrefix(value, "ASIA")) {
|
||||
return false
|
||||
}
|
||||
for _, r := range value[4:] {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resourceTokenPlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
switch normalized {
|
||||
|
||||
@@ -47,30 +47,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
|
||||
inPrivateKey = false
|
||||
}
|
||||
for _, location := range credentialAssignmentRE.FindAllStringIndex(line, -1) {
|
||||
rawMatch := line[location[0]:location[1]]
|
||||
if !validCredentialAssignmentStart(line, location[0], rawMatch) {
|
||||
continue
|
||||
}
|
||||
match := credentialAssignmentRE.FindStringSubmatch(rawMatch)
|
||||
if !isCredentialAssignmentMatch(rawMatch) {
|
||||
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
|
||||
if !isCredentialAssignmentMatch(match[0]) {
|
||||
continue
|
||||
}
|
||||
value := credentialAssignmentValue(match)
|
||||
keyName, _ := normalizedCredentialAssignmentKey(rawMatch)
|
||||
evidenceValue := value
|
||||
if sourceCodeFile(file) {
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, location[0], rawMatch); ok {
|
||||
evidenceValue = rhs
|
||||
}
|
||||
}
|
||||
if !(isWebhookCredentialKey(keyName) && webhookAssignmentValueLooksCredentialLike(value)) &&
|
||||
!credentialValueHasStrongEvidence(keyName, evidenceValue) {
|
||||
continue
|
||||
}
|
||||
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
||||
if value == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isBenignCodeCredentialExpression(file, line, location[0], rawMatch, value) ||
|
||||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
isPermissionScopeIdentifierAssignment(keyName, value) ||
|
||||
isResourceTokenPlaceholderAssignment(keyName, value) {
|
||||
@@ -79,7 +64,7 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
if looksLikeEqualityComparison(value) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(rawMatch)))
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
|
||||
}
|
||||
for _, match := range jwtLikeRE.FindAllString(line, -1) {
|
||||
if !isJWTToken(match) {
|
||||
@@ -138,43 +123,21 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
return out
|
||||
}
|
||||
|
||||
func validCredentialAssignmentStart(line string, start int, match string) bool {
|
||||
if start <= 0 || credentialAssignmentOperator(match) != ":" {
|
||||
return true
|
||||
}
|
||||
prefix := strings.TrimSpace(line[:start])
|
||||
for _, arrow := range []string{"-->>", "->>", "-->", "->"} {
|
||||
if strings.HasSuffix(prefix, arrow) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialAssignmentOperator(match string) string {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimSpace(match[len(key):])
|
||||
if strings.HasPrefix(rest, ":=") {
|
||||
return ":="
|
||||
}
|
||||
if strings.HasPrefix(rest, ":") {
|
||||
return ":"
|
||||
}
|
||||
if strings.HasPrefix(rest, "=") {
|
||||
return "="
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isCredentialAssignmentMatch(match string) bool {
|
||||
name, _, ok := normalizedCredentialAssignment(match)
|
||||
name, value, ok := normalizedCredentialAssignment(match)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name) || isWebhookCredentialKey(name)
|
||||
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
|
||||
return true
|
||||
}
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
}
|
||||
|
||||
func normalizedCredentialAssignmentKey(match string) (string, bool) {
|
||||
@@ -325,7 +288,7 @@ func tokenLikePlaceholderKey(key string) bool {
|
||||
|
||||
func tokenLikePlaceholderValue(key, value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || credentialShapedIdentifier(strings.Trim(value, `"'`)) {
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
if authCredentialTokenKey(key) {
|
||||
@@ -360,8 +323,52 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
||||
return stars >= 6 && alnum > 0
|
||||
}
|
||||
|
||||
func isWeakTokenCredentialKey(key string) bool {
|
||||
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
|
||||
return false
|
||||
}
|
||||
return key == "token" ||
|
||||
strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func isStrongTokenCredentialKey(key string) bool {
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
for _, phrase := range [][2]string{
|
||||
{"access", "token"},
|
||||
{"refresh", "token"},
|
||||
{"auth", "token"},
|
||||
{"bearer", "token"},
|
||||
{"session", "token"},
|
||||
{"service", "token"},
|
||||
{"bot", "token"},
|
||||
{"api", "token"},
|
||||
{"secret", "token"},
|
||||
} {
|
||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func weakTokenValueLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
if normalized == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isPlaceholderValue(value) {
|
||||
return false
|
||||
}
|
||||
candidate := unwrapCredentialValue(normalized)
|
||||
return credentialShapedIdentifier(candidate) ||
|
||||
highEntropyCredentialValue(candidate) ||
|
||||
commandSubstitutionLooksCredentialLike(normalized) ||
|
||||
(strings.Contains(normalized, "://") &&
|
||||
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
|
||||
}
|
||||
|
||||
func unwrapCredentialValue(value string) string {
|
||||
value = strings.TrimSpace(strings.Trim(value, "\"'<>`"))
|
||||
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
|
||||
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
|
||||
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||
}
|
||||
@@ -481,20 +488,17 @@ func numericStringPlaceholderValue(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isBenignCodeCredentialExpression(file, line string, matchStart int, match, value string) bool {
|
||||
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||
return true
|
||||
}
|
||||
if !sourceCodeFile(file) {
|
||||
if !sourceCodeFile(file) || credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, matchStart, match); ok {
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
|
||||
return isBenignTypedCredentialRHS(rhs)
|
||||
}
|
||||
if credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
|
||||
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
|
||||
return true
|
||||
@@ -514,16 +518,17 @@ func isBenignCodeCredentialExpression(file, line string, matchStart int, match,
|
||||
return codeReferenceExpression(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (string, bool) {
|
||||
if matchStart < 0 || matchStart+len(match) > len(line) || line[matchStart:matchStart+len(match)] != match {
|
||||
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
|
||||
idx := strings.Index(line, match)
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(line[matchStart+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, ":=") {
|
||||
rest := strings.TrimSpace(line[idx+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
return "", false
|
||||
}
|
||||
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
|
||||
@@ -531,12 +536,7 @@ func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (st
|
||||
if assignmentIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
rhs := strings.TrimSpace(typeAndRHS[assignmentIdx+1:])
|
||||
parsed := credentialAssignmentRE.FindStringSubmatch("client_secret=" + rhs)
|
||||
if parsed == nil {
|
||||
return rhs, true
|
||||
}
|
||||
return credentialAssignmentValue(parsed), true
|
||||
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
|
||||
}
|
||||
|
||||
func isBenignTypedCredentialRHS(value string) bool {
|
||||
@@ -568,7 +568,7 @@ func credentialAssignmentRawValueQuoted(match string) bool {
|
||||
|
||||
func sourceCodeFile(file string) bool {
|
||||
switch filepath.Ext(file) {
|
||||
case ".go", ".js", ".jsx", ".py", ".sh", ".ts", ".tsx":
|
||||
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -593,7 +593,6 @@ func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
|
||||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
|
||||
sourceCodeCredentialTermLiteral(literal) ||
|
||||
sourceCodeCredentialPrefixLiteral(literal) ||
|
||||
sourceCodeStringExpressionLiteral(literal) ||
|
||||
sourceCodeVocabularyLiteral(literal) ||
|
||||
sourceCodeSchemaTypeLiteral(literal) ||
|
||||
benignCredentialStatusLiteral(literal)
|
||||
@@ -686,18 +685,6 @@ func sourceCodeCredentialPrefixLiteral(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCodeStringExpressionLiteral(value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" ||
|
||||
credentialShapedIdentifier(normalized) ||
|
||||
highEntropyCredentialValue(strings.ToLower(normalized)) {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(normalized, "${") ||
|
||||
strings.Contains(normalized, "$(") ||
|
||||
(strings.Contains(normalized, `\b`) && strings.ContainsAny(normalized, "|[]{}()+*?"))
|
||||
}
|
||||
|
||||
func sourceCodeVocabularyLiteral(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "bot", "tenant", "user":
|
||||
@@ -766,7 +753,7 @@ func codeIdentifier(value string) bool {
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[", `\`:
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -993,7 +980,6 @@ func credentialURLPasswordFixture(password string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
||||
switch normalized {
|
||||
case "p",
|
||||
"p%40ss",
|
||||
"pass",
|
||||
"password",
|
||||
"pat_abc",
|
||||
|
||||
@@ -251,22 +251,26 @@ func TestScanFileDoesNotTreatURLEncodedCredentialAsPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsReadablePlaceholderMarkerSubstrings(t *testing.T) {
|
||||
func TestScanFileDoesNotTreatPlaceholderMarkerSubstringsAsPlaceholders(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"API_KEY=notredactedreal",
|
||||
"API_KEY=notplaceholdersecret",
|
||||
"API_KEY=abcxxxxreal",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable credential words should not be findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("placeholder-marker substring findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
|
||||
paddedSecretPrefix := "dGhpc2lz" + "YXNlY3JldA"
|
||||
paddedTokenPrefix := "UTdrMm1O" + "OXBSNHZYOA"
|
||||
paddedTokenPrefix := "YWJj" + "ZGVmZ2g"
|
||||
paddedSecret := base64PaddedFixture(paddedSecretPrefix)
|
||||
paddedToken := base64PaddedFixture(paddedTokenPrefix)
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
@@ -290,25 +294,17 @@ func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsReadableBase64Lookalike(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte("client_secret=placeholder=\n"))
|
||||
if findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("readable base64 lookalike should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
jsonToken := providerValue
|
||||
jsonSecret := providerValue
|
||||
jsonKey := providerValue
|
||||
jsonTenantToken := providerValue
|
||||
jsonAppSecret := providerValue
|
||||
jsonPrefixedKey := providerValue
|
||||
jsonTenantCamelToken := providerValue
|
||||
jsonGithubToken := providerValue
|
||||
jsonVendorKey := providerValue
|
||||
jsonSlackBotToken := "xoxb_" + "1234567890abcdef"
|
||||
jsonToken := "real-json-token"
|
||||
jsonSecret := "real " + "secret value"
|
||||
jsonKey := "real-json-key"
|
||||
jsonTenantToken := "real-tenant-json-token"
|
||||
jsonAppSecret := "real-app-secret"
|
||||
jsonPrefixedKey := "real-prefixed-key"
|
||||
jsonTenantCamelToken := "real-tenant-camel-token"
|
||||
jsonGithubToken := "real-github-token"
|
||||
jsonVendorKey := "real-vendor-key"
|
||||
jsonSlackBotToken := "xoxb-real-token"
|
||||
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
|
||||
`{"access_` + `token":"` + jsonToken + `"}`,
|
||||
`{"client_` + `secret": "` + jsonSecret + `"}`,
|
||||
@@ -338,13 +334,12 @@ func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_OPENAI: " + providerValue,
|
||||
"TOKEN_GITHUB: " + providerValue,
|
||||
"CLIENT_SECRET_GOOGLE: " + providerValue,
|
||||
"SECRET_KEY_BASE: " + providerValue,
|
||||
"APP_PASSWORD_PROD: " + providerValue,
|
||||
"API_KEY_OPENAI: real-openai-key",
|
||||
"TOKEN_GITHUB: real-github-token",
|
||||
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||
"SECRET_KEY_BASE: real-secret-key-base",
|
||||
"APP_PASSWORD_PROD: real-prod-password",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -352,7 +347,13 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{providerValue} {
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -363,77 +364,85 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
func TestScanFileDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_OPENAI: prod_key",
|
||||
"CLIENT_SECRET_GOOGLE: prod_secret",
|
||||
"TOKEN_GITHUB: github_token",
|
||||
"APP_PASSWORD_PROD: prod_password",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: "API_KEY: <" + stripeLike + ">", want: true},
|
||||
{name: "github", text: "SECRET_TOKEN: <" + patLike + ">", want: true},
|
||||
{name: "readable", text: "CLIENT_SECRET: <real-client-secret-value>", want: false},
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY: <" + stripeLike + ">",
|
||||
"SECRET_TOKEN: <" + patLike + ">",
|
||||
"CLIENT_SECRET: <real-client-secret-value>",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
|
||||
})
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "expiry provider token", text: `{"access_token_expires_in":"` + patLike + `"}`, want: true},
|
||||
{name: "expiry provider secret", text: `{"refresh_token_expires_in":"` + stripeLike + `"}`, want: true},
|
||||
{name: "status readable", text: `{"client_secret_status":"real-client-secret-value"}`, want: false},
|
||||
{name: "name readable", text: `{"client_secret_name":"real-client-secret-value"}`, want: false},
|
||||
{name: "app provider token", text: `{"app_token":"` + patLike + `"}`, want: true},
|
||||
{name: "sync provider secret", text: `{"sync_token":"` + stripeLike + `"}`, want: true},
|
||||
{name: "target readable", text: `{"target_token":"real-client-secret-value"}`, want: false},
|
||||
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
|
||||
`{"access_token_expires_in":"` + patLike + `"}`,
|
||||
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
|
||||
`{"client_secret_status":"real-client-secret-value"}`,
|
||||
`{"client_secret_name":"real-client-secret-value"}`,
|
||||
`{"app_token":"` + patLike + `"}`,
|
||||
`{"sync_token":"` + stripeLike + `"}`,
|
||||
`{"target_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/public.json", tc.text, tc.want)
|
||||
})
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
func TestScanFileDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_NAME: prod_key",
|
||||
"CLIENT_SECRET_NAME: prod_secret",
|
||||
"SECRET_STATUS: prod_secret",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsAccessKeyCredentials(t *testing.T) {
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
"ACCESS_KEY_ID: " + accessKey,
|
||||
@@ -584,18 +593,18 @@ func TestScanFileAllowsCredentialReferenceValues(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsMalformedGithubExpressionCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "provider", text: "API_KEY=${{" + stripeLike + "}}", want: true},
|
||||
{name: "readable", text: "TOKEN=${{real-secret-token-value}}", want: false},
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY=${{" + stripeLike + "}}",
|
||||
"TOKEN=${{real-secret-token-value}}",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
|
||||
})
|
||||
if count != 2 {
|
||||
t.Fatalf("malformed GitHub expression credential findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,7 +648,6 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
|
||||
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@proxy:8080"`,
|
||||
`proxy := "http://user:p%40ss@proxy:8080/path"`,
|
||||
`repo := "https://u:t@h/r.git"`,
|
||||
`target := "https://attacker:pw@open.feishu.cn"`,
|
||||
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
|
||||
@@ -813,151 +821,35 @@ func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsStrongAuthTokenKeysWithoutStrongValueEvidence(t *testing.T) {
|
||||
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"access_token":"img_abc123"}`,
|
||||
`{"api_token":"img_live_secret"}`,
|
||||
`{"service_token":"ab********cd"}`,
|
||||
`{"bot_token":"board_v3_example"}`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("token field names alone should not produce findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(strings.Join([]string{
|
||||
`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`,
|
||||
`cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600)`,
|
||||
`rt := &stubRoundTripper{respBody: ` + "`" + `{"access_token":"t","token_type":"Bearer"}` + "`" + `}`,
|
||||
`envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_auto\nFEISHU_APP_SECRET=auto_secret\n"), 0600)`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_new_app\nFEISHU_APP_SECRET=new_secret\n"), 0600)`,
|
||||
`if got := out.String(); got != "username=x-access-token\npassword=valid-pat\n\n" {`,
|
||||
`if got := out.String(); got != "username=x-access-token\npassword=restored-pat\n\n" {`,
|
||||
`if got := stdout.String(); got != "username=x-access-token\npassword=pat-token\n\n" {`,
|
||||
`return &core.CliConfig{AppID: "dummy", AppSecret: "dummy"}`,
|
||||
`os.WriteFile(path, []byte("API_KEY=replace-me\n"), 0600)`,
|
||||
`body := "APP_ID=\"cli_xxxxx\"\nAPP_SECRET=\"xxxxx\"\n"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialIdentifierFields(t *testing.T) {
|
||||
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
|
||||
`"api_key_id": "k1",`,
|
||||
`"secret_id": "s1",`,
|
||||
`"token_id": "t1",`,
|
||||
`"private_key_id": "pk1",`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("credential identifier fields should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedIdentifierFieldValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
|
||||
`"api_key_id": "` + stripeLike + `",`,
|
||||
`"token_id": "` + githubToken + `",`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("credential-shaped identifier field findings = %d, want 2: %#v", count, got)
|
||||
if count != 4 {
|
||||
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialShapedValueTrimsWhitespaceBeforeDelimiters(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
if !credentialShapedValue(` "` + providerValue + `" `) {
|
||||
t.Fatal("space-padded quoted provider credential should be recognized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsProviderCredentialsAcrossAssignmentSyntaxes(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
text string
|
||||
}{
|
||||
{name: "Go raw string", path: "pkg/config.go", text: "const clientSecret = `" + providerValue + "`"},
|
||||
{name: "TypeScript template literal", path: "pkg/config.ts", text: "const clientSecret = `" + providerValue + "`;"},
|
||||
{name: "shell backtick", path: "scripts/config.sh", text: "client_secret=`" + providerValue + "`"},
|
||||
{name: "YAML string tag", path: "docs/config.yaml", text: "client_secret: !!str " + providerValue},
|
||||
{name: "YAML string tag double quoted", path: "docs/config.yaml", text: `client_secret: !!str "` + providerValue + `"`},
|
||||
{name: "YAML string tag single quoted", path: "docs/config.yaml", text: `client_secret: !!str '` + providerValue + `'`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ScanFile(tt.path, []byte(tt.text+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("provider credential should be reported: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPercentEncodedProviderCredential(t *testing.T) {
|
||||
providerBody := strings.Join([]string{"1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
tests := []string{
|
||||
"access_token: ghp%" + "5F" + providerBody,
|
||||
"access_token_hash: ghp%" + "255F" + providerBody,
|
||||
}
|
||||
for _, text := range tests {
|
||||
got := ScanFile("docs/config.yaml", []byte(text+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("percent-encoded provider credential should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileRequiresCompleteProviderCredentialFormats(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"token_type: asian",
|
||||
"token_prefix: ASIA",
|
||||
"token_prefix: ghp_",
|
||||
"api_key: sk_live_example",
|
||||
"token_prefix: asianmarketsegment01",
|
||||
"token_prefix: ghp_placeholder_value",
|
||||
}, "\n")+"\n"))
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("incomplete provider prefixes should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsEncodedTokenMetadataURL(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte("token_url: https%3A%2F%2Fexample.invalid/oauth/token\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("encoded token metadata URL should not be credential finding: %#v", got)
|
||||
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte(strings.Join([]string{
|
||||
"var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)",
|
||||
"REALISTIC_TOKEN_RE=\"\\\"${TOKEN_BODY}\\\"|\\`${TOKEN_BODY}\\`|\\\\b${TOKEN_BODY}\\\\b\"",
|
||||
}, "\n")+"\n"))
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
|
||||
@@ -1035,22 +927,6 @@ func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsSourceCodeSyntheticCredentialIdentifiers(t *testing.T) {
|
||||
got := ScanFile("fixtures/sheets_media.go", []byte(strings.Join([]string{
|
||||
`const fakeOfficeTokenPrefix = "fake_office_"`,
|
||||
`const localOfficeTokenPrefix = "local_office_"`,
|
||||
`const imageLiveSecretMarker = "img_live_secret"`,
|
||||
`const imageProdKeyMarker = "img_prod_key"`,
|
||||
`if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {`,
|
||||
`if strings.HasPrefix(spreadsheetToken, localOfficeTokenPrefix) {`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("source code token prefix references should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`app_secret=***`,
|
||||
@@ -1065,18 +941,22 @@ func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
|
||||
"client_secret=realprefix***realsuffix",
|
||||
"client_secret=ab********cd",
|
||||
"access_token=ab********cd",
|
||||
"refresh_token=realprefix********realsuffix",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("partially masked values should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
@@ -1092,7 +972,6 @@ func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
@@ -1101,47 +980,32 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
{
|
||||
name: "typescript simple secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `"`,
|
||||
text: `const clientSecret: string = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "typescript terminated secret",
|
||||
name: "typescript numeric password",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `";`,
|
||||
},
|
||||
{
|
||||
name: "typescript secret with trailing comment",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `"; // production`,
|
||||
},
|
||||
{
|
||||
name: "typescript asserted secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `" as const;`,
|
||||
},
|
||||
{
|
||||
name: "typescript provider password",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const password: string = "` + providerValue + `"`,
|
||||
text: `const password: string = "12345678901234567890"`,
|
||||
},
|
||||
{
|
||||
name: "typescript union secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string | undefined = "` + providerValue + `"`,
|
||||
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python simple secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str = "` + providerValue + `"`,
|
||||
text: `self.client_secret: str = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python union secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str | None = "` + providerValue + `"`,
|
||||
text: `self.client_secret: str | None = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python optional secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: Optional[str] = "` + providerValue + `"`,
|
||||
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -1154,154 +1018,24 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsRepeatedTypedCredentialAssignments(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "placeholder";`, false)
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "`+providerValue+`";`, true)
|
||||
|
||||
got := ScanFile("fixtures/source_secret.ts", []byte(
|
||||
`const clientSecret: string = "placeholder"; const clientSecret: string = "`+providerValue+`";`+"\n",
|
||||
))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("repeated typed credential findings = %d, want 1: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: `const ClientSecret = "` + stripeLike + `"`, want: true},
|
||||
{name: "github", text: `const GithubToken = "` + githubToken + `"`, want: true},
|
||||
{name: "password number", text: `const Password = "12345678901234567890"`, want: false},
|
||||
{name: "secret number", text: `const ClientSecretNumber = "12345678901234567890"`, want: false},
|
||||
{name: "format literal", text: `const ClientSecretFormat = "abc%sdefreal"`, want: false},
|
||||
{name: "inline format literal", text: `fmt.Println("done"); const ClientSecret = "abc%sdefreal"`, want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.go", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsGoShortDeclarationCredentials(t *testing.T) {
|
||||
providerSecret := "sk_" + "live_1234567890abcdef"
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
|
||||
`clientSecret := "` + providerSecret + `"`,
|
||||
`accessToken := "` + providerToken + `"`,
|
||||
`const ClientSecret = "real-client-secret-value"`,
|
||||
`const GithubToken = "` + githubToken + `"`,
|
||||
`const Password = "12345678901234567890"`,
|
||||
`const ClientSecretNumber = "12345678901234567890"`,
|
||||
`const ClientSecretFormat = "abc%sdefreal"`,
|
||||
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
|
||||
}, "\n")+"\n"))
|
||||
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("Go short declaration credential findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericCredentialDecisionMatrix(t *testing.T) {
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
|
||||
tokenHash := "6f1ed002ab559585" + "9014ebf0951522d9" +
|
||||
"a0e3c1f4206254d" + "28a13efbbc8d56a30"
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
text string
|
||||
comment bool
|
||||
want bool
|
||||
}{
|
||||
{name: "source synthetic token prefix", path: "pkg/sheets.go", text: `const localOfficeTokenPrefix = "local_office_"`, want: false},
|
||||
{name: "source token kind state", path: "pkg/client.py", text: `self._token_kind: TokenKind | None = None`, want: false},
|
||||
{name: "documentation token prefix", path: "docs/config.yaml", text: `token_prefix: local_office_`, want: false},
|
||||
{name: "documentation token kind", path: "docs/config.yaml", text: `token_kind: bearer`, want: false},
|
||||
{name: "documentation token hash", path: "docs/config.yaml", text: `access_token_hash: ` + tokenHash, want: false},
|
||||
{name: "comment fixture placeholder", text: `AppSecret: "fake-secret"`, comment: true, want: false},
|
||||
{name: "test fixture placeholder", path: "pkg/config_test.go", text: `AppSecret: "fake-secret"`, want: false},
|
||||
{name: "test real-labeled token", path: "pkg/config_test.go", text: `token: "real-tenant-access-token"`, want: false},
|
||||
{name: "test ambiguous concrete secret word", path: "pkg/config_test.go", text: `AppSecret: "supersecret"`, want: false},
|
||||
{name: "resource token placeholder", path: "docs/images.md", text: `"token": "img_abc123"`, want: false},
|
||||
{name: "partially masked token", path: "docs/auth.md", text: `token=ab********cd`, want: false},
|
||||
{name: "source readable secret words", path: "pkg/config.go", text: `const AppSecret = "customer-prod-secret"`, want: false},
|
||||
{name: "documentation readable secret words", path: "docs/config.yaml", text: `client_secret: customer-prod-secret`, want: false},
|
||||
{name: "comment middle fixture marker", text: `API_KEY=prod-fake-key`, comment: true, want: false},
|
||||
{name: "comment negated fixture marker", text: `AppSecret: "not-fake-secret"`, comment: true, want: false},
|
||||
{name: "source with credential words", path: "pkg/config.go", text: `secretWithPassword := "hunter2"`, want: false},
|
||||
{name: "production filename containing sample", path: "pkg/sampler.go", text: `clientSecret := "customer-prod-secret"`, want: false},
|
||||
{name: "provider token under weak key", path: "docs/config.yaml", text: `token: ` + providerToken, want: true},
|
||||
{name: "provider token under hash key", path: "docs/config.yaml", text: `access_token_hash: ` + providerToken, want: true},
|
||||
{name: "high entropy strong secret", path: "docs/config.yaml", text: `client_secret: ` + highEntropyValue, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got []Finding
|
||||
if tt.comment {
|
||||
got = ScanComment("issue_comment", tt.text)
|
||||
} else {
|
||||
got = ScanFile(tt.path, []byte(tt.text+"\n"))
|
||||
}
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileClassifiesLowEvidenceTestFixtureCredentials(t *testing.T) {
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{name: "human readable access token", value: "user-access-token", want: false},
|
||||
{name: "delimited secret value", value: "secret-value", want: false},
|
||||
{name: "underscored secret fixture", value: "plain_secret", want: false},
|
||||
{name: "short delimited fixture", value: "t-abc", want: false},
|
||||
{name: "embedded test marker", value: "perm-grant-test-secret-skip", want: false},
|
||||
{name: "real labeled fixture", value: "real-token", want: false},
|
||||
{name: "ambiguous concrete word", value: "supersecret", want: false},
|
||||
{name: "provider token", value: providerToken, want: true},
|
||||
{name: "high entropy secret", value: highEntropyValue, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ScanFile("pkg/config_test.go", []byte(`AppSecret: "`+tt.value+`"`+"\n"))
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsLowEvidenceTestFixtureAssignmentSyntaxes(t *testing.T) {
|
||||
got := ScanFile("pkg/config_test.go", []byte(strings.Join([]string{
|
||||
`secret := "secret-value"`,
|
||||
`samplePassword := "sample-password"`,
|
||||
`bodyWithToken := "plain text body\\nDownload: https://example.com/file?token=tok_aaa\\n"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("low-evidence test fixture assignment should not be reported: %#v", got)
|
||||
}
|
||||
if count != 6 {
|
||||
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1382,10 +1116,9 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
|
||||
`{"client_token":"` + stripeLike + `"}`,
|
||||
`{"client_token":"` + githubToken + `"}`,
|
||||
`{"client_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -1419,10 +1152,9 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`{ "resource_token": "` + stripeLike + `" }`,
|
||||
`{ "block_token": "` + githubToken + `" }`,
|
||||
`{ "block_token": "real-client-secret-value" }`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -1636,43 +1368,39 @@ func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsInvalidProviderPlaceholderLookalikes(t *testing.T) {
|
||||
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"client_secret: " + stripeLike + "_HERE",
|
||||
"api_key: YOUR_" + stripeLike,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("invalid provider placeholder lookalike should not be blocked: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: "CLIENT_SECRET=%" + stripeLike + "%", want: true},
|
||||
{name: "github", text: "GITHUB_TOKEN=%" + patLike + "%", want: true},
|
||||
{name: "readable", text: "TOKEN=%real-secret-token-value%", want: false},
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"CLIENT_SECRET=%" + stripeLike + "%",
|
||||
"GITHUB_TOKEN=%" + patLike + "%",
|
||||
"TOKEN=%real-secret-token-value%",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.md", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertGenericCredentialFinding(t *testing.T, file, text string, want bool) {
|
||||
t.Helper()
|
||||
got := ScanFile(file, []byte(text+"\n"))
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, want, got)
|
||||
if count != 3 {
|
||||
t.Fatalf("percent-wrapped credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ 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"
|
||||
@@ -727,11 +726,7 @@ func appendDryRunArg(raw string) ([]string, error) {
|
||||
return nil, fmt.Errorf("not a lark-cli command")
|
||||
}
|
||||
argv = truncateShellTail(argv)
|
||||
var jqValid bool
|
||||
argv, jqValid = stripDryRunJQFilter(argv)
|
||||
if jqValid {
|
||||
argv = forceDryRunJSONFormat(argv)
|
||||
}
|
||||
argv = forceDryRunJSONFormat(argv)
|
||||
hasDryRunArg := false
|
||||
dryRunEnabled := false
|
||||
for _, arg := range argv[1:] {
|
||||
@@ -780,73 +775,6 @@ 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,38 +194,6 @@ 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{{
|
||||
@@ -827,72 +795,6 @@ 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",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,7 +15,6 @@ import (
|
||||
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -203,8 +203,7 @@ func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
|
||||
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
publicDoc := "api_" + "key = \"" + providerValue + "\"\n" +
|
||||
publicDoc := "api_" + "key = \"example-public-key\"\n" +
|
||||
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
|
||||
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -600,8 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
commandArgs := append([]string{"-c", "core.hooksPath=/dev/null"}, args...)
|
||||
cmd := gitcmd.Command(repo, commandArgs...)
|
||||
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -101,7 +101,6 @@ func TestSelectRecommendedScope_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestComputeMinimumScopeSet(t *testing.T) {
|
||||
ensureFreshRegistry(t)
|
||||
minSet := ComputeMinimumScopeSet("user")
|
||||
if len(minSet) == 0 {
|
||||
if len(ListFromMetaProjects()) == 0 {
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package gitcmd provides Git process helpers for tests that use temporary
|
||||
// repositories.
|
||||
package gitcmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
maintenanceAutoDetach = "maintenance.autoDetach"
|
||||
gcAutoDetach = "gc.autoDetach"
|
||||
)
|
||||
|
||||
// Command creates a Git command whose automatic maintenance stays in the
|
||||
// command lifecycle, so temporary repository cleanup cannot race a detached
|
||||
// maintenance process.
|
||||
func Command(dir string, args ...string) *exec.Cmd {
|
||||
commandArgs := make([]string, 0, len(args)+4)
|
||||
commandArgs = append(commandArgs,
|
||||
"-c", maintenanceAutoDetach+"=false",
|
||||
"-c", gcAutoDetach+"=false",
|
||||
)
|
||||
commandArgs = append(commandArgs, args...)
|
||||
cmd := exec.Command("git", commandArgs...)
|
||||
cmd.Dir = dir
|
||||
return cmd
|
||||
}
|
||||
|
||||
// SetSynchronousMaintenanceEnv applies the same lifecycle contract to every
|
||||
// Git process started by the current test, including processes created through
|
||||
// production command runners. Tests using it must not run in parallel.
|
||||
func SetSynchronousMaintenanceEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
count := 0
|
||||
if value, ok := os.LookupEnv("GIT_CONFIG_COUNT"); ok {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 0 {
|
||||
t.Fatalf("invalid GIT_CONFIG_COUNT %q", value)
|
||||
}
|
||||
count = parsed
|
||||
}
|
||||
for _, key := range []string{maintenanceAutoDetach, gcAutoDetach} {
|
||||
index := strconv.Itoa(count)
|
||||
t.Setenv("GIT_CONFIG_KEY_"+index, key)
|
||||
t.Setenv("GIT_CONFIG_VALUE_"+index, "false")
|
||||
count++
|
||||
}
|
||||
t.Setenv("GIT_CONFIG_COUNT", strconv.Itoa(count))
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcmd
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommandDisablesDetachedMaintenance(t *testing.T) {
|
||||
for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
|
||||
cmd := Command(t.TempDir(), "config", "--get", "--type=bool", key)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git config %s: %v\n%s", key, err, out)
|
||||
}
|
||||
if got := strings.TrimSpace(string(out)); got != "false" {
|
||||
t.Fatalf("%s = %q, want false", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSynchronousMaintenanceEnv(t *testing.T) {
|
||||
t.Setenv("GIT_CONFIG_COUNT", "1")
|
||||
t.Setenv("GIT_CONFIG_KEY_0", "user.name")
|
||||
t.Setenv("GIT_CONFIG_VALUE_0", "Existing Test User")
|
||||
SetSynchronousMaintenanceEnv(t)
|
||||
for key, want := range map[string]string{
|
||||
"user.name": "Existing Test User",
|
||||
maintenanceAutoDetach: "false",
|
||||
gcAutoDetach: "false",
|
||||
} {
|
||||
cmd := exec.Command("git", "config", "--get", "--type=bool", key)
|
||||
if key == "user.name" {
|
||||
cmd = exec.Command("git", "config", "--get", key)
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git config %s: %v\n%s", key, err, out)
|
||||
}
|
||||
if got := strings.TrimSpace(string(out)); got != want {
|
||||
t.Fatalf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@ func SafeInputPath(path string) (string, error) {
|
||||
return localfileio.SafeInputPath(path)
|
||||
}
|
||||
|
||||
// SafeTempAbsInputPath accepts an absolute read path only when it resolves
|
||||
// under the system temp dir. Delegates to localfileio.SafeTempAbsInputPath.
|
||||
func SafeTempAbsInputPath(path string) (string, error) {
|
||||
return localfileio.SafeTempAbsInputPath(path)
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// Delegates to localfileio.SafeEnvDirPath.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -18,10 +19,35 @@ func SafeOutputPath(path string) (string, error) {
|
||||
}
|
||||
|
||||
// SafeInputPath validates an upload/read source path for --file flags.
|
||||
// Deliberately strict (relative-to-cwd only): several callers — drive sync,
|
||||
// upload flags, the CI quality gates — treat "absolute paths rejected" as a
|
||||
// load-bearing invariant. The one deliberate exception is the @file payload
|
||||
// expansion, which layers SafeTempAbsInputPath on top (see cmdutil).
|
||||
func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
// SafeTempAbsInputPath accepts an absolute READ path only when it resolves
|
||||
// under the canonical system temp dir. Agents stage generated payloads
|
||||
// (batch operations JSON, CSV) in /tmp as a matter of course, and rejecting
|
||||
// @/tmp/ops.json only pushed them through an extra python/stdin round trip
|
||||
// (recurring friction cluster in eval traces). Reads under os.TempDir()
|
||||
// carry no write risk and no project-escape risk. Errors for anything else
|
||||
// (relative paths included) — callers fall back to SafeInputPath semantics.
|
||||
func SafeTempAbsInputPath(path string) (string, error) {
|
||||
if err := charcheck.RejectControlChars(path, "--file"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isAbsolutePath(path) {
|
||||
return "", fmt.Errorf("--file %q is not an absolute path", path)
|
||||
}
|
||||
resolved, ok := absPathUnderTempDir(path)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("--file must be a relative path within the current directory, or an absolute path under the system temp dir (%s), got %q (hint: use ./filename or a %s path; flags that support stdin can read any file via '-' instead)", os.TempDir(), path, os.TempDir())
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -96,6 +122,26 @@ func safePath(raw, flagName string) (string, error) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// absPathUnderTempDir accepts an absolute path only when, after cleaning and
|
||||
// resolving symlinks (through the nearest existing ancestor for
|
||||
// not-yet-created files), it still lives under the canonical system temp dir.
|
||||
// A symlink inside the temp dir pointing outside it resolves outside and is
|
||||
// rejected.
|
||||
func absPathUnderTempDir(raw string) (string, bool) {
|
||||
canonicalTmp, err := filepath.EvalSymlinks(os.TempDir())
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
resolved, err := resolveNearestAncestor(filepath.Clean(raw))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !isUnderDir(resolved, canonicalTmp) || resolved == canonicalTmp {
|
||||
return "", false
|
||||
}
|
||||
return resolved, true
|
||||
}
|
||||
|
||||
func resolveNearestAncestor(path string) (string, error) {
|
||||
var tail []string
|
||||
cur := path
|
||||
|
||||
@@ -175,7 +175,7 @@ func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
func TestSafeUploadPath_RejectsTempFileAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||
if err != nil {
|
||||
@@ -185,15 +185,67 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
f.Close()
|
||||
t.Cleanup(func() { os.Remove(tmpPath) })
|
||||
|
||||
// WHEN: SafeUploadPath validates the absolute temp path
|
||||
// WHEN: SafeInputPath validates the absolute temp path
|
||||
_, err = SafeInputPath(tmpPath)
|
||||
|
||||
// THEN: absolute paths are rejected even in temp dir
|
||||
// THEN: the strict validator rejects it — uploads / drive sync rely on
|
||||
// relative-only; temp-dir reads go through SafeTempAbsInputPath instead
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute temp path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeTempAbsInputPath(t *testing.T) {
|
||||
t.Run("accepts a file under the temp dir", func(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "payload-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
tmpPath := f.Name()
|
||||
f.Close()
|
||||
t.Cleanup(func() { os.Remove(tmpPath) })
|
||||
|
||||
resolved, err := SafeTempAbsInputPath(tmpPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected temp path accepted, got %v", err)
|
||||
}
|
||||
canonical, err := filepath.EvalSymlinks(tmpPath)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks: %v", err)
|
||||
}
|
||||
if resolved != canonical {
|
||||
t.Fatalf("resolved = %q, want %q", resolved, canonical)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects relative paths", func(t *testing.T) {
|
||||
if _, err := SafeTempAbsInputPath("./ops.json"); err == nil {
|
||||
t.Fatal("expected error for relative path, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects absolute paths outside the temp dir", func(t *testing.T) {
|
||||
if _, err := SafeTempAbsInputPath("/etc/passwd"); err == nil {
|
||||
t.Fatal("expected error for non-temp absolute path, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a temp-dir symlink escaping outside", func(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "escape-*")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
link := filepath.Join(dir, "escape.json")
|
||||
if err := os.Symlink("/etc/passwd", link); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
if _, err := SafeTempAbsInputPath(link); err == nil {
|
||||
t.Fatal("expected error for symlink escaping the temp dir, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
|
||||
@@ -34,12 +34,7 @@ func writeFixture(t *testing.T, files fixtureRepo) string {
|
||||
|
||||
func runGit(t *testing.T, root string, args ...string) string {
|
||||
t.Helper()
|
||||
commandArgs := []string{
|
||||
"-c", "maintenance.autoDetach=false",
|
||||
"-c", "gc.autoDetach=false",
|
||||
}
|
||||
commandArgs = append(commandArgs, args...)
|
||||
cmd := exec.Command("git", commandArgs...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = root
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -48,14 +43,6 @@ func runGit(t *testing.T, root string, args ...string) string {
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func TestRunGitDisablesDetachedMaintenance(t *testing.T) {
|
||||
for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
|
||||
if got := runGit(t, t.TempDir(), "config", "--get", "--type=bool", key); got != "false" {
|
||||
t.Fatalf("%s = %q, want false", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSubtypeAllowlist_ExtractsTypedConstValues(t *testing.T) {
|
||||
root := writeFixture(t, fixtureRepo{
|
||||
"errs/subtypes.go": `package errs
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.74",
|
||||
"version": "1.0.72",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -1,469 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const automationSkillDoc = "../../skills/lark-apps/references/lark-apps-automation.md"
|
||||
const localDevSkillDoc = "../../skills/lark-apps/references/lark-apps-local-dev.md"
|
||||
const larkAppsSkillDoc = "../../skills/lark-apps/SKILL.md"
|
||||
const releaseGetSkillDoc = "../../skills/lark-apps/references/lark-apps-release-get.md"
|
||||
|
||||
func readAutomationSkillDoc(t *testing.T) string {
|
||||
return readAppsSkillDoc(t, automationSkillDoc)
|
||||
}
|
||||
|
||||
func readLocalDevSkillDoc(t *testing.T) string {
|
||||
return readAppsSkillDoc(t, localDevSkillDoc)
|
||||
}
|
||||
|
||||
func readReleaseGetSkillDoc(t *testing.T) string {
|
||||
return readAppsSkillDoc(t, releaseGetSkillDoc)
|
||||
}
|
||||
|
||||
func readAppsSkillDoc(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read skill doc %s: %v", path, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func skillSection(t *testing.T, doc, heading string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(doc, heading)
|
||||
if start < 0 {
|
||||
t.Fatalf("missing skill section %q", heading)
|
||||
}
|
||||
rest := doc[start+len(heading):]
|
||||
if next := strings.Index(rest, "\n## "); next >= 0 {
|
||||
return rest[:next]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
func skillSubsection(t *testing.T, doc, heading string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(doc, heading)
|
||||
if start < 0 {
|
||||
t.Fatalf("missing skill subsection %q", heading)
|
||||
}
|
||||
rest := doc[start+len(heading):]
|
||||
end := len(rest)
|
||||
for _, marker := range []string{"\n### ", "\n## "} {
|
||||
if next := strings.Index(rest, marker); next >= 0 && next < end {
|
||||
end = next
|
||||
}
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
func requireInOrder(t *testing.T, text string, tokens ...string) {
|
||||
t.Helper()
|
||||
offset := 0
|
||||
for _, token := range tokens {
|
||||
idx := strings.Index(text[offset:], token)
|
||||
if idx < 0 {
|
||||
t.Fatalf("missing %q after %q", token, text[:offset])
|
||||
}
|
||||
offset += idx + len(token)
|
||||
}
|
||||
}
|
||||
|
||||
func requireFirstOccurrencesInOrder(t *testing.T, text string, tokens ...string) {
|
||||
t.Helper()
|
||||
previous := -1
|
||||
for _, token := range tokens {
|
||||
idx := strings.Index(text, token)
|
||||
if idx < 0 {
|
||||
t.Fatalf("missing %q", token)
|
||||
}
|
||||
if idx <= previous {
|
||||
t.Fatalf("first %q at %d must follow the previous contract token at %d", token, idx, previous)
|
||||
}
|
||||
previous = idx
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_ChangedHandlerStartWaitsForThisRelease(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 实现或更新 handler 后发布并启动/测试")
|
||||
|
||||
requireInOrder(t, section,
|
||||
"仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler",
|
||||
"+automation-get",
|
||||
"记录发布前状态",
|
||||
"--name",
|
||||
"项目 guide",
|
||||
"按项目 guide 完成同名业务 handler 并本地验证。",
|
||||
"在 Git 已确认/预授权时 commit,然后执行",
|
||||
"git push origin sprint/default",
|
||||
"临时停用授权",
|
||||
"+automation-disable",
|
||||
"确认 disabled",
|
||||
"+release-create --branch sprint/default",
|
||||
"data.release_id",
|
||||
"+release-get",
|
||||
"data.status=finished",
|
||||
"仅启动",
|
||||
"+automation-enable",
|
||||
"+automation-get",
|
||||
"不制造 runtime probe",
|
||||
"测试",
|
||||
"运行时验证的操作级授权",
|
||||
"完成全部 preflight",
|
||||
"才执行 `+automation-enable`",
|
||||
"真实 runtime",
|
||||
"仅要求测试",
|
||||
"恢复到发布前状态",
|
||||
)
|
||||
requireFirstOccurrencesInOrder(t, section,
|
||||
"+automation-get",
|
||||
"git push origin sprint/default",
|
||||
"临时停用授权",
|
||||
"+automation-disable",
|
||||
"+release-create --branch sprint/default",
|
||||
"data.status=finished",
|
||||
"仅启动",
|
||||
)
|
||||
for _, boundary := range []string{
|
||||
"仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler,且用户要求把这次代码发布后启动或测试时,才使用此路径。",
|
||||
"按项目 guide 完成同名业务 handler 并本地验证。",
|
||||
"在 Git 已确认/预授权时 commit,然后执行 `git push origin sprint/default`。",
|
||||
"若该命令本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release(新代码未上线),原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled 后停止;若因超时等导致结果未知,保持 disabled,先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
|
||||
"只有 `data.status=finished` 才能继续;`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟。",
|
||||
"确认 `failed` 时报告发布失败,原本 enabled 的 trigger 仅在确认新代码未上线后恢复 enabled,原本 disabled 的保持 disabled。",
|
||||
"发布状态仍不确定时不得进入 enable、probe 或状态恢复分支。",
|
||||
"**仅启动**:取得持续启动授权后执行 `+automation-enable`,并用 `+automation-get` 确认 enabled;到此结束,不制造 runtime probe。",
|
||||
"**测试(含“启动并测试”)**:先按下节“运行时验证的操作级授权”完成全部 preflight",
|
||||
"若用户仅要求测试而不是持续启动,只在本轮 release 已 `finished` 且 probe 成功后恢复到发布前状态",
|
||||
"无论用户是仅测试还是启动并测试,probe 失败、结果不确定或 enable 后提前结束时,一律 `+automation-disable` 并回读 disabled",
|
||||
"不得把“发布前 enabled”当作失败后的恢复依据",
|
||||
"没有通用的 `automation-debug` 或 trigger 日志 shortcut。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("complete-start section must explain %q boundary", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_BindsTheExactNameAsUser(t *testing.T) {
|
||||
doc := readAutomationSkillDoc(t)
|
||||
for _, boundary := range []string{
|
||||
"全部操作需 `--as user`(AuthType: user)。",
|
||||
"当用户希望触发器实际执行业务代码时,先确认当前工作区是已初始化的应用项目,并读取其中与触发器任务匹配的 guide。",
|
||||
"`--name` 是应用内唯一的 trigger 定位键;代码侧绑定名称必须与它逐字相同。不得用 trigger ID 或方法名代替它。具体 handler 语法和接入方式以项目 guide 为准。",
|
||||
} {
|
||||
if !strings.Contains(doc, boundary) {
|
||||
t.Errorf("automation skill must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_RoutesAndDiagnosesUnfiredTriggers(t *testing.T) {
|
||||
doc := readAutomationSkillDoc(t)
|
||||
routeSection := skillSection(t, doc, "## 何时用本 skill(路由锚点)")
|
||||
errorSection := skillSection(t, doc, "## 常见错误与决策场景")
|
||||
|
||||
if !strings.Contains(routeSection, "「触发器没反应 / enable 了不触发 / 为什么没执行 / 验证一下触发器」→ 先按「未触发时的诊断顺序」诊断;对 UPSERT 和 feishu-approval 仅验证配置边界,不承诺 handler 或 live 验证。") {
|
||||
t.Error("routing anchors must direct unfired triggers to the bounded diagnostic flow")
|
||||
}
|
||||
if !strings.Contains(errorSection, "已证实的 cron、webhook、record-change(INSERT/UPDATE/DELETE)按「未触发时的诊断顺序」排查;UPSERT 和 feishu-approval 仅核对配置边界,不承诺 handler 或 live 验证。") {
|
||||
t.Error("error table must preserve the bounded unfired-trigger diagnostic flow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_ConfigurationStopsDisabled(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅创建/配置触发器")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"用 `+automation-create` 创建,并省略 `--status` 或显式传 `disabled`,然后报告 name 和 disabled 状态。",
|
||||
"不要传 `--status enabled`,也不要写 handler、commit/push、release 或 enable;更不能把创建 API 成功称为“可运行”。",
|
||||
"默认 disabled 是这个意图的终点,不是稍后自动 enable 的待办。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("configuration-only section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_EnableExistingTriggerDoesNotPublish(t *testing.T) {
|
||||
doc := readAutomationSkillDoc(t)
|
||||
section := skillSubsection(t, doc, "### 仅启用已有 disabled trigger")
|
||||
routeSection := skillSection(t, doc, "## 何时用本 skill(路由锚点)")
|
||||
|
||||
requireInOrder(t, section,
|
||||
"用户只要求启用已存在且 disabled 的 trigger",
|
||||
"+automation-get",
|
||||
"+release-list --status finished --page-size 1",
|
||||
"已完成线上 release",
|
||||
"当前线上应用",
|
||||
"不能证明该 trigger name 已绑定 handler",
|
||||
"+automation-enable",
|
||||
"+automation-get",
|
||||
"不得修改 handler、commit/push 或 release",
|
||||
"对 UPSERT 或 feishu-approval 只改变配置状态",
|
||||
)
|
||||
if !strings.Contains(section, "未发布时不得自动创建 release,也不得声称 trigger 已开始实际运行") {
|
||||
t.Error("enable-only flow must distinguish configuration enablement from a published runtime")
|
||||
}
|
||||
if !strings.Contains(section, "即使存在 finished release,也只能把 enable 报告为配置激活") {
|
||||
t.Error("enable-only flow must not infer handler provenance from app release history")
|
||||
}
|
||||
if strings.Contains(section, "apps +get") || strings.Contains(section, "`is_published`") {
|
||||
t.Error("enable-only flow must use finished release history instead of an optional app detail field")
|
||||
}
|
||||
for _, forbidden := range []string{"git push", "+release-create"} {
|
||||
if strings.Contains(section, forbidden) {
|
||||
t.Errorf("enable-only flow must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(routeSection, "「启用 / 启动已有 trigger」→ 先核对现有状态;只启用时不要修改源码或发布应用。") {
|
||||
t.Error("routing anchors must keep existing-trigger enablement separate from code release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_TestExistingTriggerDoesNotPublish(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 测试已有线上 trigger(不改代码)")
|
||||
|
||||
requireInOrder(t, section,
|
||||
"用户要求测试已经发布的 trigger",
|
||||
"+automation-get",
|
||||
"+release-list --status finished --page-size 1",
|
||||
"当前线上代码",
|
||||
"不得为测试自动修改源码、commit/push 或 release",
|
||||
"在任何临时 enable 之前完成",
|
||||
"测试请求已明确包含临时 enable,或另行取得 enable 授权",
|
||||
"运行时验证的操作级授权",
|
||||
"无论 probe 成功、失败、结果不确定,还是临时 enable 后提前结束或中断,最终都必须 `+automation-disable` 并回读 disabled",
|
||||
)
|
||||
for _, forbidden := range []string{"git push", "+release-create"} {
|
||||
if strings.Contains(section, forbidden) {
|
||||
t.Errorf("existing-trigger test flow must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_HandlerOnlyStopsBeforeRelease(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler(不发布/不启用)")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"创建或定位已明确 name 的 disabled trigger,读取项目 guide,按其要求实现同名业务 handler,完成本地验证。",
|
||||
"只在既有 Git 确认或预授权下 commit/push;停止在 `+release-create` 和 `+automation-enable` 之前。",
|
||||
"用户没有明确“发布好”时,先问,不能默认把完整应用上线。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("handler-only section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_HandlerOnlyExcludesUnverifiedRuntimeTypes(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler(不发布/不启用)")
|
||||
|
||||
if !strings.Contains(section, "仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。") {
|
||||
t.Error("handler-only flow must exclude UPSERT and feishu-approval without a verified runtime contract")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_PublishedHandlerStaysDisabled(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 把 handler 发布好,但先不要启动")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。",
|
||||
"先用 `+automation-get` 定位;不存在时用 `+automation-create` 创建同名 disabled trigger,再次回读确认。",
|
||||
"已存在时记录它是否 enabled。",
|
||||
"若 trigger 已 enabled,先说明发布前必须临时停用以及可能造成的运行中断,并取得这次临时停用授权;未获授权时停止在发布前。",
|
||||
"取得授权后,在发布前执行 `+automation-disable`,并再次用 `+automation-get` 确认 disabled。",
|
||||
"按项目 guide 完成同名业务 handler 并本地验证后,commit、`git push origin sprint/default`。",
|
||||
"随后发布完整应用:",
|
||||
"若 `+release-create` 本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release(新代码未上线),原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled,然后停止;若因超时等导致创建结果未知,保持 disabled,先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
|
||||
"取得 `data.release_id` 后,对**这一轮** ID 调用 `+release-get`:`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时且状态仍不确定时报告 `release_id` 和当前 status,并保持 disabled;只有 `data.status=finished` 才算完成。",
|
||||
"确认 `failed` 且新代码未上线时,原本 enabled 的 trigger 恢复 enabled 并回读,原本 disabled 的保持 disabled。",
|
||||
"release 是整个应用上线,可能影响既有线上功能;未获得启动或测试授权时,finished 后始终保持 disabled,不执行 `+automation-enable`。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("publish-without-start section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
requireFirstOccurrencesInOrder(t, section,
|
||||
"+automation-get",
|
||||
"git push origin sprint/default",
|
||||
"临时停用授权",
|
||||
"+automation-disable",
|
||||
"+release-create",
|
||||
)
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_UPSERTAndApprovalStayConfigurationOnly(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### UPSERT 与飞书审批边界")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"record-change 的 UPSERT 可创建 disabled 配置,但当前没有已证实的运行时代码契约;不得静默按 UPDATE 处理,也不得承诺 handler 或 live 验证。",
|
||||
"feishu-approval 可创建 disabled 配置,并读取或更新 `event_type`、对应 status 和可选 `approval_code`。",
|
||||
"当前没有已证实的运行时 handler 契约或实际投递验证;不要把 enable 或审批 API 成功称为业务代码已执行。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("UPSERT/approval boundary section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_RuntimeProbeRequiresOperationScope(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 运行时验证的操作级授权")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"启用 trigger 的授权不等于制造 runtime 事件的授权,测试授权也不等于任意数据库写入授权。",
|
||||
"record-change 在执行任何 DML 前,必须明确并取得覆盖以下作用域的授权",
|
||||
"环境、表、操作、精确测试记录或筛选条件、payload、预期结果和清理方式",
|
||||
"优先使用专用测试记录",
|
||||
"`DELETE`",
|
||||
"[lark-apps-db-execute.md](lark-apps-db-execute.md)",
|
||||
"先 `SELECT count(*)`、执行 `--dry-run`",
|
||||
"取得针对该删除目标的明确授权",
|
||||
"+automation-list --trigger-type record-change --all",
|
||||
"同一环境、表和操作可能命中的其他 enabled trigger",
|
||||
"聚合业务影响",
|
||||
"恢复 UPDATE 或清理 INSERT 也可能再次触发自动化",
|
||||
"缺少安全、已授权且可清理的事件入口时,记录 blocked",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("runtime probe section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_UsesResolvableSharedSkillLink(t *testing.T) {
|
||||
doc := readAutomationSkillDoc(t)
|
||||
|
||||
if strings.Contains(doc, "](../lark-shared/SKILL.md)") {
|
||||
t.Error("automation reference must not resolve lark-shared inside the lark-apps directory")
|
||||
}
|
||||
if !strings.Contains(doc, "](../../lark-shared/SKILL.md)") {
|
||||
t.Error("automation reference must link to the sibling lark-shared skill")
|
||||
}
|
||||
sharedSkillDoc := filepath.Clean(filepath.Join(filepath.Dir(automationSkillDoc), "../../lark-shared/SKILL.md"))
|
||||
if _, err := os.Stat(sharedSkillDoc); err != nil {
|
||||
t.Fatalf("automation reference target %s must exist: %v", sharedSkillDoc, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSkillContract_AllSharedSkillLinksResolve(t *testing.T) {
|
||||
docs := []string{larkAppsSkillDoc}
|
||||
references, err := filepath.Glob("../../skills/lark-apps/references/*.md")
|
||||
if err != nil {
|
||||
t.Fatalf("glob lark-apps references: %v", err)
|
||||
}
|
||||
docs = append(docs, references...)
|
||||
sharedLink := regexp.MustCompile(`\]\(([^)]+lark-shared/SKILL\.md)\)`)
|
||||
|
||||
for _, docPath := range docs {
|
||||
doc := readAppsSkillDoc(t, docPath)
|
||||
for _, match := range sharedLink.FindAllStringSubmatch(doc, -1) {
|
||||
target := filepath.Clean(filepath.Join(filepath.Dir(docPath), match[1]))
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Errorf("%s shared-skill link %q resolves to missing target %s: %v", docPath, match[1], target, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDevSkillContract_UsesProjectGuideWithoutSyncInternals(t *testing.T) {
|
||||
section := skillSection(t, readLocalDevSkillDoc(t), "## Trigger guide 的项目边界")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"先查看工作区 `.agents/skills/`,读取与自动化任务匹配的 `trigger-guide`。",
|
||||
"文件缺失或不能覆盖当前任务时,报告项目缺少可用的领域 guide;不要在本 lark-cli reference 中猜测安装命令、版本或包内目录。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("trigger-guide boundary section must explain %q", boundary)
|
||||
}
|
||||
}
|
||||
for _, implementationShape := range []string{
|
||||
"npx ", "skills sync", "data.", "skills_", "_CACHE_DIR", "nestjs-",
|
||||
"@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
|
||||
} {
|
||||
if strings.Contains(section, implementationShape) {
|
||||
t.Errorf("local-dev skill must not expose project-sync implementation shape %q", implementationShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSkillContract_DoesNotExposeSteeringImplementation(t *testing.T) {
|
||||
for name, doc := range map[string]string{
|
||||
"automation": readAutomationSkillDoc(t),
|
||||
"local-dev": readLocalDevSkillDoc(t),
|
||||
} {
|
||||
for _, implementationShape := range []string{
|
||||
"npx ", "skills sync", "@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
|
||||
} {
|
||||
if strings.Contains(doc, implementationShape) {
|
||||
t.Errorf("%s skill must not expose project-sync implementation shape %q", name, implementationShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDevSkillContract_UsesEnvironmentAndDefersEnableToAutomationSOP(t *testing.T) {
|
||||
doc := readLocalDevSkillDoc(t)
|
||||
releaseSection := skillSection(t, doc, "## 改完代码后部署上线")
|
||||
for _, legacy := range []string{"--env dev", "--env online"} {
|
||||
if strings.Contains(doc, legacy) {
|
||||
t.Errorf("local-dev skill must not recommend legacy %q", legacy)
|
||||
}
|
||||
}
|
||||
for _, boundary := range []string{
|
||||
"`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时仍未完成时停止本轮轮询、报告 `release_id` 和当前 status。",
|
||||
"若本次改动包含自动化 handler,在执行本节通用 commit/push/release 序列前就转到 [automation SOP](lark-apps-automation.md) 的匹配路径,由该 SOP 负责完整的状态门禁、commit/push、release 和可选 enable/test;不要先按本节发布再补 trigger 状态检查。",
|
||||
"用户只要求启用已有 trigger 时,转到 [automation SOP 的「仅启用已有 disabled trigger」路径](lark-apps-automation.md#仅启用已有-disabled-trigger);不得因 enable 反向修改 handler、commit/push 或 release。",
|
||||
"使用 `--environment dev|online`,不要使用旧的 `--env`。只有确认应用已开启多环境时才引导 `--environment dev`;单环境应用省略 `--environment`(服务端选 online)或显式传 `--environment online`。",
|
||||
} {
|
||||
if !strings.Contains(doc, boundary) {
|
||||
t.Errorf("local-dev skill must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
routeIndex := strings.Index(releaseSection, "若本次改动包含自动化 handler")
|
||||
releaseIndex := strings.Index(releaseSection, "+release-create")
|
||||
if routeIndex < 0 || releaseIndex < 0 || routeIndex >= releaseIndex {
|
||||
t.Error("automation routing must appear before the generic release sequence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDevSkillContract_DoesNotRequireOnlineURL(t *testing.T) {
|
||||
section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
|
||||
|
||||
if strings.Contains(section, "`finished` 成功时该命令输出已含 `online_url`") {
|
||||
t.Error("release guidance must not claim every finished release includes online_url")
|
||||
}
|
||||
if !strings.Contains(section, "若返回 `online_url`,可直接使用;未返回时不要编造链接。") {
|
||||
t.Error("release guidance must explain that online_url is optional")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDevSkillContract_TreatsErrorLogsAsOptional(t *testing.T) {
|
||||
section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
|
||||
|
||||
if !strings.Contains(section, "`failed` 时若返回非空 `error_logs`,据此给出失败原因;否则只报告 `release_id` 和当前 status,不要编造原因") {
|
||||
t.Error("release guidance must not promise error_logs on every failed release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseSkillContract_TreatsOptionalOutputAsOptional(t *testing.T) {
|
||||
releaseGet := readReleaseGetSkillDoc(t)
|
||||
for _, boundary := range []string{
|
||||
"`finished` 后才可能有 `online_url`。",
|
||||
"若输出含 `online_url`,直接读取它作为本轮发布的线上访问链接;未返回时只报告发布完成,不要编造链接。",
|
||||
"若输出含 `error_logs`(`step`/`error_log`),据此向用户转述关键失败步骤和可行动修复;未返回时不要编造失败原因。",
|
||||
} {
|
||||
if !strings.Contains(releaseGet, boundary) {
|
||||
t.Errorf("release-get skill must preserve optional-output boundary %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
)
|
||||
|
||||
func appsValidationError(format string, args ...any) *errs.ValidationError {
|
||||
@@ -73,3 +74,32 @@ func appsInputPathEntryError(path string, err error) error {
|
||||
func appsFileIOError(err error, format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// enrichHTMLPublishAPIError adapts a typed failure from the HTML publish
|
||||
// endpoint: refines endpoint-scoped business codes, prefixes the message with
|
||||
// command context, and attaches endpoint-specific recovery hints. A
|
||||
// still-untyped error is lifted at the SDK boundary instead.
|
||||
func enrichHTMLPublishAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return client.WrapDoAPIError(err)
|
||||
}
|
||||
// The HTML publish business codes (90001/90002) are scoped to this
|
||||
// endpoint, not service-global, so their subtype classification lives
|
||||
// here instead of the global errclass code table. Only an
|
||||
// otherwise-unclassified API error is refined; a stronger upstream
|
||||
// classification is never overridden.
|
||||
if p.Category == errs.CategoryAPI && p.Subtype == errs.SubtypeUnknown && p.Code == errCodeAppNotFound {
|
||||
p.Subtype = errs.SubtypeNotFound
|
||||
}
|
||||
if p.Message != "" {
|
||||
p.Message = "html-publish failed: " + p.Message
|
||||
}
|
||||
if hint := buildHTMLPublishFailureHint(p.Code); hint != "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -57,3 +57,57 @@ func TestAppsFileIOError_ClassifiesInternalFileIO(t *testing.T) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_LiftsUntypedBoundaryError(t *testing.T) {
|
||||
err := enrichHTMLPublishAPIError(errors.New("connection reset by peer"))
|
||||
|
||||
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
|
||||
if problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_PreservesClassificationAndAddsHint(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "build failed").
|
||||
WithCode(errCodeBuildFailed).
|
||||
WithLogID("logid-build-failed")
|
||||
|
||||
got := enrichHTMLPublishAPIError(err)
|
||||
if got != err {
|
||||
t.Fatalf("typed error should be enriched in place")
|
||||
}
|
||||
problem := requireAppsAPIProblem(t, got)
|
||||
if problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
if problem.Code != errCodeBuildFailed {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
|
||||
}
|
||||
if problem.LogID != "logid-build-failed" {
|
||||
t.Fatalf("log_id = %q, want preserved", problem.LogID)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "html-publish failed") {
|
||||
t.Fatalf("message = %q, want html-publish context", problem.Message)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected known-code recovery hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_ClassifiesAppNotFoundLocally(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "app not found").WithCode(errCodeAppNotFound)
|
||||
|
||||
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
|
||||
if problem.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_KeepsStrongerClassification(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeRateLimit, "throttled").WithCode(errCodeAppNotFound)
|
||||
|
||||
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
|
||||
if problem.Subtype != errs.SubtypeRateLimit {
|
||||
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeRateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,10 @@ import (
|
||||
var AppsGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+get",
|
||||
Description: "Get a single app's detail by app ID or meta token (returns app_type, name, description, publish status, etc.)",
|
||||
Description: "Get a single app's detail by app ID (returns app_type, name, description, publish status, etc.)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +get --app-id <app_id>",
|
||||
"Example: lark-cli apps +get --app-id <meta_token>",
|
||||
"Example: lark-cli apps +get --app-id <app_id> --dry-run",
|
||||
"Tip: extract app type with --jq '.data.app.app_type'",
|
||||
},
|
||||
@@ -29,7 +28,7 @@ var AppsGet = common.Shortcut{
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID or meta token", Required: true},
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
@@ -41,7 +40,7 @@ var AppsGet = common.Shortcut{
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Get app detail (returns app_id, meta_token, app_type, name, description, icon_url, created_at, updated_at, is_published)")
|
||||
Desc("Get app detail (returns app_id, app_type, name, description, icon_url, created_at, updated_at, is_published)")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
@@ -55,9 +54,6 @@ var AppsGet = common.Shortcut{
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "app_id: %v\n", app["app_id"])
|
||||
if mt, ok := app["meta_token"].(string); ok && mt != "" {
|
||||
fmt.Fprintf(w, "meta_token: %s\n", mt)
|
||||
}
|
||||
fmt.Fprintf(w, "app_type: %v\n", app["app_type"])
|
||||
fmt.Fprintf(w, "name: %v\n", app["name"])
|
||||
if desc, ok := app["description"].(string); ok && desc != "" {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -37,13 +38,9 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
{Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / .aws/credentials / etc. in the publish payload)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if err := validateRealAppID(appID); err != nil {
|
||||
return err
|
||||
}
|
||||
path := strings.TrimSpace(rctx.Str("path"))
|
||||
if path == "" {
|
||||
return appsValidationParamError("--path", "--path is required")
|
||||
@@ -76,11 +73,9 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := strings.TrimSpace(rctx.Str("path"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Pack tar.gz → GET pre_release for TOS upload URL → PUT tar.gz to TOS → POST release-create with tos_path; returns release_id")
|
||||
dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
PUT("<presigned_upload_url> (from pre_release response)").
|
||||
POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))).
|
||||
Body(map[string]string{"tos_path": "<from pre_release response>"})
|
||||
dry.Desc("Pack tar.gz and publish HTML app (actual API path determined at runtime by app type; returns url or release_id)")
|
||||
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Set("content_type", "multipart/form-data")
|
||||
|
||||
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
@@ -128,7 +123,16 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
Path: strings.TrimSpace(rctx.Str("path")),
|
||||
}
|
||||
|
||||
out, err := runHTMLPublishTOS(ctx, rctx, spec)
|
||||
appType := queryAppType(ctx, rctx, spec.AppID)
|
||||
|
||||
var out map[string]interface{}
|
||||
var err error
|
||||
if appType == "modern_html" {
|
||||
out, err = runHTMLPublishTOS(ctx, rctx, spec)
|
||||
} else {
|
||||
client := appsHTMLPublishAPI{runtime: rctx}
|
||||
out, err = runHTMLPublish(ctx, rctx.FileIO(), client, spec)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -260,7 +264,25 @@ func prepareHTMLPublishTarball(fio fileio.FileIO, path string) (*htmlPublishTarb
|
||||
return tarball, nil
|
||||
}
|
||||
|
||||
// runHTMLPublishTOS handles the publish path: validate → tar.gz →
|
||||
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
tarball, err := prepareHTMLPublishTarball(fio, spec.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
|
||||
if err != nil {
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{}
|
||||
if resp.URL != "" {
|
||||
out["url"] = resp.URL
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runHTMLPublishTOS handles the modern_html publish path: validate → tar.gz →
|
||||
// call pre_release to get TOS upload URL → upload tar.gz to TOS → return
|
||||
// tos_path for +release-create --tos-path.
|
||||
func runHTMLPublishTOS(ctx context.Context, rctx *common.RuntimeContext, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -22,6 +23,20 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type fakeAppsHTMLPublishClient struct {
|
||||
resp *htmlPublishResponse
|
||||
err error
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeAppsHTMLPublishClient) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
|
||||
f.calls = append(f.calls, appID)
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.resp, nil
|
||||
}
|
||||
|
||||
func writeAppsSampleSite(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -31,19 +46,71 @@ func writeAppsSampleSite(t *testing.T) string {
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_PathNotFound(t *testing.T) {
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), "/nonexistent")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
func TestRunHTMLPublish_HappyPath(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
fake := &fakeAppsHTMLPublishClient{
|
||||
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
|
||||
}
|
||||
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if out["url"] != "https://miaoda/app_x" {
|
||||
t.Fatalf("url=%v", out["url"])
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0] != "app_x" {
|
||||
t.Fatalf("calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
|
||||
func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
|
||||
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步" (legacy html path only):
|
||||
// envelope 只含 url,未来若有人加 status / release_id 字段会被这个测试拦截。
|
||||
site := writeAppsSampleSite(t)
|
||||
fake := &fakeAppsHTMLPublishClient{
|
||||
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
|
||||
}
|
||||
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("envelope should only contain 'url', got %d keys: %v", len(out), out)
|
||||
}
|
||||
if _, ok := out["url"]; !ok {
|
||||
t.Fatalf("envelope missing 'url': %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_ClientErrorPropagated(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
wantErr := errors.New("server timeout")
|
||||
fake := &fakeAppsHTMLPublishClient{err: wantErr}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_PathNotFound(t *testing.T) {
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: "/nonexistent"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client should not be called when path invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
|
||||
// 目录形态:缺 index.html 应该被拦
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing index.html")
|
||||
}
|
||||
@@ -54,9 +121,13 @@ func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client should not be called when index.html missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
|
||||
func TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) {
|
||||
// 目录含 index.html 应该正常走完
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
@@ -64,49 +135,57 @@ func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "extra.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
if err != nil {
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if tarball == nil || tarball.Size == 0 {
|
||||
t.Fatalf("expected non-empty tarball")
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called when index.html present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
|
||||
func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
|
||||
// 单文件形态:文件名不是 index.html 也要拦
|
||||
dir := t.TempDir()
|
||||
single := filepath.Join(dir, "foo.html")
|
||||
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), single)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single})
|
||||
if err == nil {
|
||||
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
|
||||
}
|
||||
requireAppsValidationProblem(t, err)
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when index.html missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_SingleFileNamedIndexPasses(t *testing.T) {
|
||||
func TestRunHTMLPublish_SingleFileNamedIndexPasses(t *testing.T) {
|
||||
// 单文件形态:文件名恰好就是 index.html → 放行
|
||||
dir := t.TempDir()
|
||||
single := filepath.Join(dir, "index.html")
|
||||
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), single)
|
||||
if err != nil {
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single}); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if tarball == nil || tarball.Size == 0 {
|
||||
t.Fatalf("expected non-empty tarball")
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called for single index.html")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
|
||||
func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
|
||||
// 把上限调到 100 字节验证拦截,defer 恢复原值避免污染其它测试。
|
||||
orig := maxHTMLPublishTarballBytes
|
||||
maxHTMLPublishTarballBytes = 100
|
||||
defer func() { maxHTMLPublishTarballBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
// 写 index.html(满足新加的 index 校验)+ 大文件超 100 字节上限。
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
@@ -115,7 +194,8 @@ func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected oversize error")
|
||||
}
|
||||
@@ -126,6 +206,9 @@ func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client should not be called when tarball oversize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHTMLPublishTarballBytes_Default(t *testing.T) {
|
||||
@@ -181,17 +264,8 @@ func TestAppsHTMLPublish_DryRunPrintsManifest(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/pre_release") {
|
||||
t.Fatalf("dry-run missing pre_release endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "presigned_upload_url") {
|
||||
t.Fatalf("dry-run missing TOS PUT step: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/releases") {
|
||||
t.Fatalf("dry-run missing release-create endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "tos_path") {
|
||||
t.Fatalf("dry-run missing tos_path in release-create body: %s", got)
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "index.html") {
|
||||
t.Fatalf("dry-run missing file list: %s", got)
|
||||
@@ -426,7 +500,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
|
||||
appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected raw-size cap to fire")
|
||||
}
|
||||
@@ -434,6 +510,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") {
|
||||
t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when raw cap hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizeHTMLFiles(t *testing.T) {
|
||||
@@ -476,7 +555,8 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected per-file oversize error")
|
||||
}
|
||||
@@ -487,9 +567,13 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when an HTML file is oversize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
@@ -501,12 +585,12 @@ func TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
if err != nil {
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
|
||||
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
|
||||
}
|
||||
if tarball == nil || tarball.Size == 0 {
|
||||
t.Fatalf("expected non-empty tarball")
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called; calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,18 +74,15 @@ type appTypePolicy struct {
|
||||
// skipSkillsSync skips the conditional `npx ... skills sync --local` step on
|
||||
// the non-empty (`app sync`) scaffold path.
|
||||
skipSkillsSync bool
|
||||
// skipAppSync skips `npx ... app sync` on the non-empty repo path.
|
||||
skipAppSync bool
|
||||
}
|
||||
|
||||
// appTypePolicies maps an app_type to its +init control strategy. Types absent
|
||||
// from the map get the zero-value policy (install runs, env is pulled, skills
|
||||
// are synced).
|
||||
var appTypePolicies = map[string]appTypePolicy{
|
||||
// modern_html / html are static HTML sites: no dependencies to install,
|
||||
// no startup env vars to pull, no steering skills to sync, and no app sync.
|
||||
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
|
||||
"html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
|
||||
// modern_html is a static HTML site: no dependencies to install, no startup
|
||||
// env vars to pull, and no steering skills to sync.
|
||||
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true},
|
||||
}
|
||||
|
||||
// policyForAppType returns the +init control strategy for appType. Unlisted
|
||||
@@ -125,13 +122,9 @@ var AppsInit = common.Shortcut{
|
||||
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if err := validateRealAppID(appID); err != nil {
|
||||
return err
|
||||
}
|
||||
if sp := strings.TrimSpace(rctx.Str("source-path")); sp != "" {
|
||||
if err := charcheck.RejectControlChars(sp, "--source-path"); err != nil {
|
||||
return appsValidationParamError("--source-path", "%v", err).WithCause(err)
|
||||
@@ -341,19 +334,11 @@ func ensureMetaAppID(dir, appID string) error {
|
||||
// each is not already resolvable from local/global/system config, so a
|
||||
// developer's existing identity is never overwritten. Each key is handled
|
||||
// independently (a machine with only user.name set still gets a default email).
|
||||
func ensureGitIdentity(ctx context.Context, dir, authorName, authorEmail string) error {
|
||||
name := strings.TrimSpace(authorName)
|
||||
if name == "" {
|
||||
name = defaultGitUserName
|
||||
}
|
||||
email := strings.TrimSpace(authorEmail)
|
||||
if email == "" {
|
||||
email = defaultGitUserEmail
|
||||
}
|
||||
if err := ensureGitConfigValue(ctx, dir, "user.name", name); err != nil {
|
||||
func ensureGitIdentity(ctx context.Context, dir string) error {
|
||||
if err := ensureGitConfigValue(ctx, dir, "user.name", defaultGitUserName); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureGitConfigValue(ctx, dir, "user.email", email)
|
||||
return ensureGitConfigValue(ctx, dir, "user.email", defaultGitUserEmail)
|
||||
}
|
||||
|
||||
// ensureGitConfigValue sets <key>=fallback in the repo-local git config when key
|
||||
@@ -415,16 +400,13 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s
|
||||
}
|
||||
return scaffoldKindInit, nil
|
||||
}
|
||||
policy := policyForAppType(appType)
|
||||
if !policy.skipAppSync {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
if err := ensureMetaAppID(dir, appID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !policy.skipSkillsSync && !hasSteeringSkills(dir) {
|
||||
if !policyForAppType(appType).skipSkillsSync && !hasSteeringSkills(dir) {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
|
||||
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
@@ -454,38 +436,26 @@ func scaffoldInitArgs(appType, appID, sourcePath string) []string {
|
||||
return base
|
||||
}
|
||||
|
||||
// credentialInitResult holds the fields parsed from +git-credential-init output.
|
||||
type credentialInitResult struct {
|
||||
RepositoryURL string
|
||||
CommitAuthorName string
|
||||
CommitAuthorEmail string
|
||||
}
|
||||
|
||||
// parseCredentialInitEnvelope extracts fields from a +git-credential-init JSON
|
||||
// envelope ({"ok":true,"data":{"repository_url":"...","commit_author_name":"...","commit_author_email":"..."}}).
|
||||
func parseCredentialInitEnvelope(stdout string) (credentialInitResult, error) {
|
||||
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
|
||||
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
|
||||
// matches the contract emitted by `apps +git-credential-init`.
|
||||
func parseRepoURLFromEnvelope(stdout string) (string, error) {
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
CommitAuthorName string `json:"commit_author_name"`
|
||||
CommitAuthorEmail string `json:"commit_author_email"`
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return credentialInitResult{}, appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
|
||||
return "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init reported failure")
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
|
||||
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
|
||||
}
|
||||
return credentialInitResult{
|
||||
RepositoryURL: env.Data.RepositoryURL,
|
||||
CommitAuthorName: env.Data.CommitAuthorName,
|
||||
CommitAuthorEmail: env.Data.CommitAuthorEmail,
|
||||
}, nil
|
||||
return env.Data.RepositoryURL, nil
|
||||
}
|
||||
|
||||
// parseEnvFileFromEnvelope extracts data.env_file from a `+env-pull` success
|
||||
@@ -557,10 +527,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
appType, err := queryAppType(ctx, rctx, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appType := queryAppType(ctx, rctx, appID)
|
||||
policy := policyForAppType(appType)
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
@@ -628,16 +595,16 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
initLogf(rctx, "Issuing repository credentials for %s...", appID)
|
||||
cred, err := issueCredentials(ctx, rctx, appID)
|
||||
repoURL, err := issueCredentials(ctx, rctx, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRepoURLScheme(cred.RepositoryURL); err != nil {
|
||||
if err := validateRepoURLScheme(repoURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initLogf(rctx, "Cloning into %s...", dir)
|
||||
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", cred.RepositoryURL, dir); err != nil {
|
||||
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
|
||||
return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
initLogf(rctx, "Checking out %s...", defaultInitBranch)
|
||||
@@ -645,10 +612,9 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
|
||||
}
|
||||
|
||||
// Ensure a committer identity exists before the scaffold commit. Uses the
|
||||
// author name/email from +git-credential-init when available; falls back
|
||||
// to lark-cli-bot defaults when the server does not provide them.
|
||||
if err := ensureGitIdentity(ctx, dir, cred.CommitAuthorName, cred.CommitAuthorEmail); err != nil {
|
||||
// Ensure a committer identity exists before the scaffold commit; only sets
|
||||
// repo-local defaults when none is configured (existing identity is kept).
|
||||
if err := ensureGitIdentity(ctx, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -677,7 +643,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
|
||||
out := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"repository_url": redactURLCredentials(cred.RepositoryURL),
|
||||
"repository_url": redactURLCredentials(repoURL),
|
||||
"branch": defaultInitBranch,
|
||||
"clone_path": dir,
|
||||
"scaffold": scaffold,
|
||||
@@ -755,10 +721,10 @@ func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string
|
||||
|
||||
// issueCredentials runs `<self> apps +git-credential-init --app-id <id> --format json`
|
||||
// and returns the repo_url it reports. Forwards --as when set.
|
||||
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (credentialInitResult, error) {
|
||||
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return credentialInitResult{}, errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
|
||||
}
|
||||
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
|
||||
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
|
||||
@@ -766,11 +732,11 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
|
||||
}
|
||||
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
|
||||
if err != nil {
|
||||
return credentialInitResult{}, appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
|
||||
return "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
|
||||
WithHint("ensure apps +git-credential-init is available and you are logged in").
|
||||
WithCause(err)
|
||||
}
|
||||
return parseCredentialInitEnvelope(stdout)
|
||||
return parseRepoURLFromEnvelope(stdout)
|
||||
}
|
||||
|
||||
// commitAndPushIfDirty commits and pushes only when the working tree has
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -111,24 +110,18 @@ func TestDefaultCloneDir(t *testing.T) {
|
||||
// --- pure-function tests ---
|
||||
|
||||
func TestParseRepoURL(t *testing.T) {
|
||||
result, err := parseCredentialInitEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git","commit_author_name":"Alice","commit_author_email":"alice@example.com"}}`)
|
||||
url, err := parseRepoURLFromEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git"}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.RepositoryURL != "http://u:t@h/app_x.git" {
|
||||
t.Errorf("RepositoryURL got %q", result.RepositoryURL)
|
||||
}
|
||||
if result.CommitAuthorName != "Alice" {
|
||||
t.Errorf("CommitAuthorName got %q", result.CommitAuthorName)
|
||||
}
|
||||
if result.CommitAuthorEmail != "alice@example.com" {
|
||||
t.Errorf("CommitAuthorEmail got %q", result.CommitAuthorEmail)
|
||||
if url != "http://u:t@h/app_x.git" {
|
||||
t.Errorf("got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepoURL_Errors(t *testing.T) {
|
||||
for _, in := range []string{`not json`, `{"ok":false,"data":{}}`, `{"ok":true,"data":{}}`, `{"ok":true,"data":{"repository_url":""}}`} {
|
||||
if _, err := parseCredentialInitEnvelope(in); err == nil {
|
||||
if _, err := parseRepoURLFromEnvelope(in); err == nil {
|
||||
t.Errorf("expected error for %q", in)
|
||||
}
|
||||
}
|
||||
@@ -156,22 +149,6 @@ func withFakeRunner(t *testing.T, f *fakeCommandRunner) {
|
||||
t.Cleanup(func() { initRunner = orig })
|
||||
}
|
||||
|
||||
func stubAppType(reg *httpmock.Registry, appID, appType string) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/" + appID,
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"app_type": appType,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func credInitOK(repoURL string) fakeCallResult {
|
||||
return fakeCallResult{stdout: `{"ok":true,"data":{"repository_url":"` + repoURL + `"}}`}
|
||||
}
|
||||
@@ -336,8 +313,7 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
|
||||
"git status": {stdout: " M src/app.ts\n"}, // scaffold produced changes
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -378,8 +354,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
}
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
}
|
||||
@@ -448,8 +423,7 @@ func TestAppsInit_HappyPathCleanTree(t *testing.T) {
|
||||
"git status": {}, // clean tree after scaffold -> no commit/push
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
@@ -498,8 +472,7 @@ func TestAppsInit_DirtyTreeCommitPush(t *testing.T) {
|
||||
"git status": {stdout: " M file.txt"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
@@ -569,8 +542,7 @@ func TestAppsInit_CloneFailure(t *testing.T) {
|
||||
"git clone": {stderr: "fatal: unable to access 'http://u:t@h/r.git'", err: errors.New("exit 128")},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
@@ -644,8 +616,7 @@ func TestAppsInit_AsPassthrough(t *testing.T) {
|
||||
"git status": {},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
|
||||
// AppsInit.AuthTypes is ["user"], so the framework rejects --as bot. Use
|
||||
@@ -751,7 +722,7 @@ func TestIsEmptyRepo(t *testing.T) {
|
||||
// newAppsExecuteFactoryWithStderr mirrors newAppsExecuteFactory but also returns
|
||||
// the stderr buffer, so tests can assert on the +init progress log lines that
|
||||
// initLogf writes to IO().ErrOut.
|
||||
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
@@ -761,12 +732,12 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, stdout, stderr, reg := cmdutil.TestFactory(t, cfg)
|
||||
return factory, stdout, stderr, reg
|
||||
factory, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
return factory, stdout, stderr
|
||||
}
|
||||
|
||||
func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
factory, stdout, _, _ := newAppsExecuteFactoryWithStderr(t)
|
||||
factory, stdout, _ := newAppsExecuteFactoryWithStderr(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
@@ -795,8 +766,7 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
"git status": {},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory2, stdout2, stderr2, reg2 := newAppsExecuteFactoryWithStderr(t)
|
||||
stubAppType(reg2, "app_x", "FULL_STACK")
|
||||
factory2, stdout2, stderr2 := newAppsExecuteFactoryWithStderr(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory2, stdout2); err != nil {
|
||||
t.Fatalf("run err=%v", err)
|
||||
@@ -859,8 +829,7 @@ func TestAppsInit_EmptyRepo_TwoCommits(t *testing.T) {
|
||||
"git status": {stdout: " A src/app.ts\n A .spark/meta.json\n A .agent/skills/steering/x.md\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -901,8 +870,7 @@ func TestAppsInit_EmptyRepo_AppCodeOnly_SingleCommit(t *testing.T) {
|
||||
"git status": {stdout: " A src/app.ts\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -922,8 +890,7 @@ func TestAppsInit_EmptyRepo_ConfigOnly_SingleCommit(t *testing.T) {
|
||||
"git status": {stdout: " A .spark/meta.json\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -943,8 +910,7 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
|
||||
"git status": {stdout: " M file.txt\n M .spark/meta.json\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -963,7 +929,8 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
|
||||
// gitMust runs a git command in dir with a real binary, failing the test on error.
|
||||
func gitMust(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := gitcmd.Command(dir, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v in %s failed: %v\n%s", args, dir, err, out)
|
||||
@@ -979,7 +946,6 @@ func TestCommitAndPushIfDirty_RealGit_IgnoredAgentDir(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
gitcmd.SetSynchronousMaintenanceEnv(t)
|
||||
// Bare remote so `git push origin sprint/default` succeeds.
|
||||
remote := t.TempDir()
|
||||
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
|
||||
@@ -1101,7 +1067,6 @@ func TestCommitAndPushIfDirty_RealGit_NonEmptyUpgrade(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
gitcmd.SetSynchronousMaintenanceEnv(t)
|
||||
remote := t.TempDir()
|
||||
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
|
||||
|
||||
@@ -1324,8 +1289,7 @@ func TestAppsInit_EnvPull_Success(t *testing.T) {
|
||||
"env-pull": envPullOK("/abs/app_x/.env.local"),
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -1363,8 +1327,7 @@ func TestAppsInit_EnvPull_NonFatal(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
|
||||
@@ -1403,8 +1366,7 @@ func TestAppsInit_AlreadyInitialized_RunsEnvPull(t *testing.T) {
|
||||
envFile := filepath.Join(abs, ".env.local")
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -1451,8 +1413,7 @@ func TestAppsInit_AlreadyInitialized_EnvPullFailure_NonFatal(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
|
||||
}
|
||||
@@ -1744,15 +1705,13 @@ func TestScaffoldInitArgs_WithAppType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPolicyForAppType(t *testing.T) {
|
||||
// modern_html and html decouple all control points: skip install, env-pull, skills sync, app sync.
|
||||
for _, at := range []string{"modern_html", "html"} {
|
||||
if p := policyForAppType(at); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync || !p.skipAppSync {
|
||||
t.Errorf("%s policy = %+v, want all skip flags set", at, p)
|
||||
}
|
||||
// modern_html decouples all control points: skip install, env-pull, skills sync.
|
||||
if p := policyForAppType("modern_html"); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync {
|
||||
t.Errorf("modern_html policy = %+v, want all skip flags set", p)
|
||||
}
|
||||
// Unlisted types (including "") get the zero-value policy: everything runs.
|
||||
for _, at := range []string{"full_stack", "", "backend"} {
|
||||
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync || p.skipAppSync {
|
||||
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync {
|
||||
t.Errorf("policy for %q = %+v, want zero value", at, p)
|
||||
}
|
||||
}
|
||||
@@ -1798,7 +1757,7 @@ func configSetValue(calls [][]string, key string) (string, bool) {
|
||||
func TestEnsureGitIdentity_SetsDefaultsWhenUnset(t *testing.T) {
|
||||
f := &fakeCommandRunner{} // no "git config" result → `--get` returns empty stdout
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v, ok := configSetValue(f.calls, "user.name"); !ok || v != defaultGitUserName {
|
||||
@@ -1815,7 +1774,7 @@ func TestEnsureGitIdentity_RespectsExisting(t *testing.T) {
|
||||
"git config": {stdout: "Existing Dev\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := configSetValue(f.calls, "user.name"); ok {
|
||||
@@ -1831,7 +1790,7 @@ func TestEnsureGitIdentity_SetFailurePropagates(t *testing.T) {
|
||||
"git config": {stderr: "boom", err: errors.New("exit 1")},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err == nil {
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err == nil {
|
||||
t.Error("expected error when git config set fails")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,25 +13,22 @@ import (
|
||||
)
|
||||
|
||||
// queryAppType fetches the app's type string from the server via
|
||||
// GET /open-apis/spark/v1/apps/{identifier}. The identifier can be either
|
||||
// an app_id or a meta_token — the server resolves both. The server returns
|
||||
// uppercase app_type values ("HTML", "FULL_STACK", "MODERN_HTML");
|
||||
// this function normalizes to lowercase. Returns an error when the API
|
||||
// is unavailable or the response is malformed — callers must not proceed
|
||||
// with a fallback type to avoid creating the wrong project scaffold.
|
||||
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, identifier string) (string, error) {
|
||||
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(identifier))
|
||||
// GET /open-apis/spark/v1/apps/{appID}. The server returns uppercase
|
||||
// values ("HTML", "FULL_STACK", "MODERN_HTML"); this function normalizes
|
||||
// to lowercase. Returns "" when the API is unavailable or returns an
|
||||
// error — callers fall back to legacy behavior.
|
||||
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, appID string) string {
|
||||
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
appRaw, _ := data["app"].(map[string]interface{})
|
||||
if appRaw == nil {
|
||||
return "", appsSubprocessEnvelopeError("query app type: response missing app object")
|
||||
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: response missing app object\n")
|
||||
return ""
|
||||
}
|
||||
appType, _ := appRaw["app_type"].(string)
|
||||
if strings.TrimSpace(appType) == "" {
|
||||
return "", appsSubprocessEnvelopeError("query app type: response missing app_type")
|
||||
}
|
||||
return strings.ToLower(appType), nil
|
||||
return strings.ToLower(appType)
|
||||
}
|
||||
|
||||
@@ -43,10 +43,7 @@ func TestQueryAppType_Success(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result, err := queryAppType(context.Background(), rt, "app_test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
result := queryAppType(context.Background(), rt, "app_test")
|
||||
if result != "modern_html" {
|
||||
t.Errorf("queryAppType = %q, want modern_html", result)
|
||||
}
|
||||
@@ -68,10 +65,7 @@ func TestQueryAppType_FullStack(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result, err := queryAppType(context.Background(), rt, "app_fs")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
result := queryAppType(context.Background(), rt, "app_fs")
|
||||
if result != "full_stack" {
|
||||
t.Errorf("queryAppType = %q, want full_stack", result)
|
||||
}
|
||||
@@ -93,10 +87,7 @@ func TestQueryAppType_Html(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result, err := queryAppType(context.Background(), rt, "app_html")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
result := queryAppType(context.Background(), rt, "app_html")
|
||||
if result != "html" {
|
||||
t.Errorf("queryAppType = %q, want html", result)
|
||||
}
|
||||
@@ -111,9 +102,9 @@ func TestQueryAppType_APIError(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
|
||||
})
|
||||
|
||||
_, err := queryAppType(context.Background(), rt, "app_bad")
|
||||
if err == nil {
|
||||
t.Error("expected error on API failure")
|
||||
result := queryAppType(context.Background(), rt, "app_bad")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty on error", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,9 +119,9 @@ func TestQueryAppType_MissingAppObject(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
_, err := queryAppType(context.Background(), rt, "app_no")
|
||||
if err == nil {
|
||||
t.Error("expected error when app object missing")
|
||||
result := queryAppType(context.Background(), rt, "app_no")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app object missing", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,8 +141,8 @@ func TestQueryAppType_EmptyAppType(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
_, err := queryAppType(context.Background(), rt, "app_empty")
|
||||
if err == nil {
|
||||
t.Error("expected error when app_type is empty")
|
||||
result := queryAppType(context.Background(), rt, "app_empty")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,9 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if err := validateRealAppID(appID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
@@ -30,13 +30,9 @@ var AppsReleaseGet = common.Shortcut{
|
||||
{Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if err := validateRealAppID(appID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("release-id")) == "" {
|
||||
return appsValidationParamError("--release-id", "--release-id is required")
|
||||
}
|
||||
|
||||
@@ -41,21 +41,6 @@ func withAppsHint(err error, hint string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// validateRealAppID checks that --app-id is a real app ID (app_ prefix).
|
||||
// meta_token values are rejected with a hint to resolve via +get first.
|
||||
func validateRealAppID(appID string) error {
|
||||
if !strings.HasPrefix(appID, "app_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
`--app-id must be an app_id starting with "app_".`,
|
||||
).WithParam("--app-id").WithHint(
|
||||
`If you have a meta_token or a /page/<token>/ link, first resolve it:
|
||||
lark-cli apps +get --app-id <meta_token> -q '.data.app.app_id'
|
||||
Then retry this command with the returned app_id.`,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied
|
||||
// --output path. The authoritative guard is the local FileIO layer
|
||||
// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and
|
||||
|
||||
@@ -75,7 +75,6 @@ var AppsGitCredentialInit = common.Shortcut{
|
||||
"save the issued PAT in the local system credential store",
|
||||
"write app-scoped git credential metadata",
|
||||
"configure a URL-scoped Git credential helper in global git config when possible",
|
||||
"return commit_author_name and commit_author_email for repo-local git identity",
|
||||
}).
|
||||
Params(gitCredentialIssueParams(appID))
|
||||
},
|
||||
@@ -91,12 +90,6 @@ var AppsGitCredentialInit = common.Shortcut{
|
||||
"repository_url": result.GitHTTPURL,
|
||||
"status": initStatus(result),
|
||||
}
|
||||
if result.CommitAuthorName != "" {
|
||||
payload["commit_author_name"] = result.CommitAuthorName
|
||||
}
|
||||
if result.CommitAuthorEmail != "" {
|
||||
payload["commit_author_email"] = result.CommitAuthorEmail
|
||||
}
|
||||
if result.ConfigWarning != "" {
|
||||
payload["git_config_warning"] = result.ConfigWarning
|
||||
}
|
||||
@@ -468,13 +461,11 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
|
||||
}
|
||||
}
|
||||
issued := &gitcred.IssuedCredential{
|
||||
AppID: firstString(source, "app_id", appID),
|
||||
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
|
||||
Username: firstString(source, "username"),
|
||||
PAT: firstString(source, "token", "Token", "pat", "password"),
|
||||
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
|
||||
CommitAuthorName: firstString(source, "commit_author_name"),
|
||||
CommitAuthorEmail: firstString(source, "commit_author_email"),
|
||||
AppID: firstString(source, "app_id", appID),
|
||||
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
|
||||
Username: firstString(source, "username"),
|
||||
PAT: firstString(source, "token", "Token", "pat", "password"),
|
||||
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
|
||||
}
|
||||
if issued.AppID == "" {
|
||||
issued.AppID = appID
|
||||
|
||||
@@ -87,7 +87,6 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
|
||||
"save the issued PAT in the local system credential store",
|
||||
"write app-scoped git credential metadata",
|
||||
"configure a URL-scoped Git credential helper in global git config when possible",
|
||||
"return commit_author_name and commit_author_email for repo-local git identity",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -129,13 +129,7 @@ func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string
|
||||
if previous != nil && previous.PATRef != "" && previous.PATRef != ref {
|
||||
_ = m.Secrets.Remove(previous.PATRef)
|
||||
}
|
||||
result := &InitResult{
|
||||
AppID: appID,
|
||||
GitHTTPURL: url,
|
||||
Refreshed: previous != nil,
|
||||
CommitAuthorName: issued.CommitAuthorName,
|
||||
CommitAuthorEmail: issued.CommitAuthorEmail,
|
||||
}
|
||||
result := &InitResult{AppID: appID, GitHTTPURL: url, Refreshed: previous != nil}
|
||||
if m.GitConfig != nil {
|
||||
if err := m.GitConfig.SetHelper(ctx, url, appID); err != nil {
|
||||
result.ConfigWarning = err.Error()
|
||||
|
||||
@@ -51,22 +51,18 @@ type CredentialRecord struct {
|
||||
}
|
||||
|
||||
type IssuedCredential struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Username string
|
||||
PAT string
|
||||
ExpiresAt int64
|
||||
CommitAuthorName string
|
||||
CommitAuthorEmail string
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Username string
|
||||
PAT string
|
||||
ExpiresAt int64
|
||||
}
|
||||
|
||||
type InitResult struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Refreshed bool
|
||||
ConfigWarning string
|
||||
CommitAuthorName string
|
||||
CommitAuthorEmail string
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Refreshed bool
|
||||
ConfigWarning string
|
||||
}
|
||||
|
||||
type RemoveResult struct {
|
||||
|
||||
73
shortcuts/apps/html_publish_client.go
Normal file
73
shortcuts/apps/html_publish_client.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type htmlPublishResponse struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
type appsHTMLPublishClient interface {
|
||||
HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error)
|
||||
}
|
||||
|
||||
type appsHTMLPublishAPI struct {
|
||||
runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddFile("file", bytes.NewReader(tarball.Body))
|
||||
|
||||
apiResp, err := api.runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID)),
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
data, err := api.runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return nil, enrichHTMLPublishAPIError(err)
|
||||
}
|
||||
url, _ := data["url"].(string)
|
||||
if url == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"html-publish response is missing the published app url")
|
||||
}
|
||||
return &htmlPublishResponse{URL: url}, nil
|
||||
}
|
||||
|
||||
// OAPI business error codes returned by the
|
||||
// /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend
|
||||
// service; update when new codes are documented in the OAPI spec.
|
||||
const (
|
||||
errCodeBuildFailed = 90001 // tar.gz uploaded but server-side build failed
|
||||
errCodeAppNotFound = 90002 // app_id unknown or caller lacks permission
|
||||
)
|
||||
|
||||
func buildHTMLPublishFailureHint(code int) string {
|
||||
switch code {
|
||||
case errCodeBuildFailed:
|
||||
return "server-side build failed: run `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` to inspect the packaged file list"
|
||||
case errCodeAppNotFound:
|
||||
return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
197
shortcuts/apps/html_publish_client_test.go
Normal file
197
shortcuts/apps/html_publish_client_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
rctx := common.TestNewRuntimeContextForAPI(context.Background(), nil, cfg, factory, core.AsUser)
|
||||
return rctx, reg
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_Success(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"url": "https://miaoda.feishu.cn/app/app_x",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
tarball := &htmlPublishTarball{Body: []byte("fake"), Size: 4, SHA256: "abc"}
|
||||
resp, err := api.HTMLPublish(context.Background(), "app_x", tarball)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if resp.URL != "https://miaoda.feishu.cn/app/app_x" {
|
||||
t.Fatalf("url=%q", resp.URL)
|
||||
}
|
||||
|
||||
ct := stub.CapturedHeaders.Get("Content-Type")
|
||||
mt, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil || mt != "multipart/form-data" {
|
||||
t.Fatalf("content type %q wrong", ct)
|
||||
}
|
||||
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
saw := false
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if p.FormName() == "file" {
|
||||
saw = true
|
||||
}
|
||||
}
|
||||
if !saw {
|
||||
t.Fatalf("multipart missing 'file' part")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": 90001,
|
||||
"msg": "build failed: dependency conflict",
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
problem := requireAppsAPIProblem(t, err)
|
||||
if problem.Code != errCodeBuildFailed {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint on code 90001")
|
||||
}
|
||||
if !strings.Contains(problem.Message, "build failed") {
|
||||
t.Fatalf("missing failure message: %v", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_AppNotFoundClassified(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_missing/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": errCodeAppNotFound,
|
||||
"msg": "app not found",
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_missing", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsAPIProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected app-not-found recovery hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_MissingURLIsInvalidResponse(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishFailureHint_UnknownCodeReturnsEmpty(t *testing.T) {
|
||||
// 默认分支:未识别的 code 返回空 hint,让 Agent 用 message 兜底。
|
||||
if hint := buildHTMLPublishFailureHint(99999); hint != "" {
|
||||
t.Fatalf("unknown code should return empty hint, got %q", hint)
|
||||
}
|
||||
if hint := buildHTMLPublishFailureHint(0); hint != "" {
|
||||
t.Fatalf("zero code should return empty hint, got %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishFailureHint_KnownCodes(t *testing.T) {
|
||||
if hint := buildHTMLPublishFailureHint(90001); hint == "" {
|
||||
t.Fatalf("code 90001 should return non-empty hint")
|
||||
}
|
||||
if hint := buildHTMLPublishFailureHint(90002); hint == "" {
|
||||
t.Fatalf("code 90002 should return non-empty hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing.T) {
|
||||
hint := buildHTMLPublishFailureHint(90002)
|
||||
if hint == "" {
|
||||
t.Fatalf("code 90002 should return non-empty hint")
|
||||
}
|
||||
if strings.Contains(hint, "+list") {
|
||||
t.Fatalf("hint must not point at hidden +list command, got: %q", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "app_id") {
|
||||
t.Fatalf("hint should reference app_id, got: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_MalformedResponseIsInvalidResponse(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
RawBody: []byte("{not json"),
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
@@ -104,22 +104,6 @@ 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) {
|
||||
@@ -133,7 +117,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 := newBaseTestRuntimeWithArrays(
|
||||
listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"field-names": {"Name", "Age"}},
|
||||
nil,
|
||||
|
||||
@@ -81,37 +81,6 @@ 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)
|
||||
@@ -153,7 +122,7 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new base." {
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
@@ -500,6 +469,9 @@ func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testi
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
@@ -605,9 +577,8 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
wantDesc := "After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
|
||||
if got := stdout.String(); !strings.Contains(got, wantDesc) {
|
||||
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -616,9 +587,8 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
wantDesc := "After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
|
||||
if got := stdout.String(); !strings.Contains(got, wantDesc) {
|
||||
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -627,7 +597,7 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access") {
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -849,189 +819,8 @@ 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1303,32 +1092,8 @@ 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"fld_new"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1375,58 +1140,11 @@ 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{
|
||||
@@ -1601,32 +1319,6 @@ 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{
|
||||
@@ -1923,162 +1615,28 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
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 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 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) {
|
||||
t.Run("list legacy fields flag rejected", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
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 {
|
||||
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.Fatalf("err=%v", err)
|
||||
}
|
||||
if body := string(searchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
})
|
||||
|
||||
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.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
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.Run("list legacy fields flag rejected in dry-run", 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") {
|
||||
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) {
|
||||
@@ -2457,14 +2015,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"ignored_fields": []interface{}{"Formula"},
|
||||
"has_more": false,
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"update": map[string]interface{}{"Status": "Done"},
|
||||
},
|
||||
},
|
||||
})
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"ignored_fields"`) || !strings.Contains(got, `"Formula"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"update"`) || !strings.Contains(got, `"Done"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -2476,16 +2036,20 @@ 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{}{},
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
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 {
|
||||
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 {
|
||||
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, `"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}`) {
|
||||
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"patch":{"Name":"Alice","Status":"Done"}`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
Body(buildBaseCopyBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
d.
|
||||
POST("/open-apis/base/v3/bases").
|
||||
|
||||
@@ -28,16 +28,23 @@ 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 {
|
||||
if name == "field-names" {
|
||||
cmd.Flags().StringSlice(name, nil, "")
|
||||
} else {
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
}
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
}
|
||||
for name := range stringSliceFlags {
|
||||
cmd.Flags().StringSlice(name, nil, "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
@@ -54,6 +61,11 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
|
||||
_ = 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")
|
||||
@@ -465,40 +477,6 @@ 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
|
||||
@@ -808,8 +786,7 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
name: "record batch update json",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
wantHelp: []string{
|
||||
"update_records maps each record ID to its field map",
|
||||
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
|
||||
`batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -845,10 +822,6 @@ 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\"]",
|
||||
@@ -881,11 +854,9 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
name: "record batch update",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
wantTips: []string{
|
||||
"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",
|
||||
"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",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"Batch update supports max 200 records per call",
|
||||
"CellValue happy path: text/phone/url",
|
||||
@@ -999,17 +970,11 @@ 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",
|
||||
@@ -1022,9 +987,6 @@ 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) {
|
||||
@@ -1147,10 +1109,6 @@ 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) {
|
||||
@@ -1272,89 +1230,13 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
)); err != nil {
|
||||
t.Fatalf("record search json with sort-json validate err=%v", err)
|
||||
}
|
||||
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
if 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,
|
||||
))
|
||||
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)
|
||||
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -5,7 +5,6 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,10 +36,7 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
|
||||
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
bodies, err := parseFieldCreateBodies(pc, runtime.Str("json"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
||||
}
|
||||
bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
|
||||
dr := common.NewDryRunAPI().
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
@@ -52,10 +48,7 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo
|
||||
|
||||
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
||||
}
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
||||
Body(body).
|
||||
@@ -173,10 +166,10 @@ func executeFieldCreate(runtime *common.RuntimeContext) error {
|
||||
fields = append(fields, data)
|
||||
}
|
||||
if len(fields) == 1 {
|
||||
runtime.Out(fieldCreateResult(map[string]interface{}{"field": fields[0], "created": true}, bodies[0]), nil)
|
||||
runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
|
||||
return nil
|
||||
}
|
||||
runtime.Out(fieldCreateBatchResult(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, bodies), nil)
|
||||
runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -204,101 +197,10 @@ func executeFieldUpdate(runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(fieldUpdateResult(map[string]interface{}{"field": data, "updated": true}, body), nil)
|
||||
runtime.Out(map[string]interface{}{"field": data, "updated": true}, 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,9 +27,7 @@ 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, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Name"}})
|
||||
fields, err = resolveRecordGetSelectFields(nil, 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"}, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,19 +12,18 @@ import (
|
||||
var BaseRecordBatchUpdate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-batch-update",
|
||||
Description: "Batch update records with record-specific fields",
|
||||
Description: "Batch update records",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:record:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(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},
|
||||
{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},
|
||||
},
|
||||
Tips: append([]string{
|
||||
"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.",
|
||||
"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.",
|
||||
"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,9 +21,7 @@ var BaseRecordGet = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||
recordProjectionFieldFlag("field ID or name to project; repeat to keep only needed columns"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
|
||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
|
||||
@@ -20,9 +20,8 @@ var BaseRecordList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
recordListFieldRefFlag(),
|
||||
recordListFieldNamesAliasFlag(),
|
||||
recordListViewRefFlag(),
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
@@ -45,6 +44,9 @@ 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
|
||||
}
|
||||
@@ -59,9 +61,6 @@ var BaseRecordList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordList,
|
||||
@@ -73,6 +72,22 @@ 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"
|
||||
@@ -87,3 +102,10 @@ 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,15 @@ 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.`,
|
||||
@@ -49,6 +46,7 @@ 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")
|
||||
@@ -71,11 +69,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
projectionFields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), body)
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
@@ -89,11 +83,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
projectionFields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), nil)
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
@@ -114,20 +104,20 @@ func normalizeRecordIDs(values interface{}) ([]string, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func resolveRecordGetSelectFields(flagFields []string, projectionParam string, body map[string]interface{}) ([]string, error) {
|
||||
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
|
||||
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
|
||||
if err != nil {
|
||||
return nil, withValidationParam(err, projectionParam)
|
||||
return nil, err
|
||||
}
|
||||
if body == nil {
|
||||
return fromFlags, nil
|
||||
}
|
||||
rawJSONFields, ok := body["select_fields"]
|
||||
if !ok || rawJSONFields == nil {
|
||||
if !ok {
|
||||
return fromFlags, nil
|
||||
}
|
||||
if len(fromFlags) > 0 {
|
||||
return nil, baseFlagErrorf(`%s and --json field "select_fields" are mutually exclusive`, projectionParam)
|
||||
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
||||
}
|
||||
items, ok := rawJSONFields.([]interface{})
|
||||
if !ok {
|
||||
@@ -138,26 +128,18 @@ func resolveRecordGetSelectFields(flagFields []string, projectionParam string, b
|
||||
}
|
||||
normalized, err := normalizeRecordGetSelectFields(items)
|
||||
if err != nil {
|
||||
return nil, withValidationParam(err, "--json")
|
||||
return nil, err
|
||||
}
|
||||
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: max,
|
||||
max: maxBatchGetSelectFieldCount,
|
||||
allowNil: true,
|
||||
allowEmpty: true,
|
||||
})
|
||||
@@ -229,11 +211,7 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
params := url.Values{}
|
||||
params.Set("offset", strconv.Itoa(offset))
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
fields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
for _, field := range fields {
|
||||
for _, field := range recordListFields(runtime) {
|
||||
params.Add("field_id", field)
|
||||
}
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
@@ -397,121 +375,11 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
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"
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
if runtime.Changed("field-names") {
|
||||
return runtime.StrSlice("field-names")
|
||||
}
|
||||
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)
|
||||
return runtime.StrArray("field-id")
|
||||
}
|
||||
|
||||
func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
@@ -524,10 +392,7 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
limit := getPaginationLimit(runtime)
|
||||
params := map[string]interface{}{"offset": offset, "limit": limit}
|
||||
fields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := recordListFields(runtime)
|
||||
if len(fields) > 0 {
|
||||
params["field_id"] = fields
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -175,10 +174,7 @@ func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{
|
||||
if len(searchFields) > 0 {
|
||||
body["search_fields"] = searchFields
|
||||
}
|
||||
selectFields, err := recordSearchProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selectFields := recordListFields(runtime)
|
||||
if len(selectFields) > 0 {
|
||||
body["select_fields"] = selectFields
|
||||
}
|
||||
@@ -207,19 +203,6 @@ 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
|
||||
@@ -236,20 +219,8 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if jsonRaw != "" {
|
||||
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.")
|
||||
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
|
||||
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
|
||||
}
|
||||
_, err := recordSearchJSONBody(runtime)
|
||||
return err
|
||||
@@ -271,31 +242,17 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordSearchProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
}
|
||||
|
||||
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 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 formatRecordQueryPriorityTip() string {
|
||||
|
||||
@@ -23,9 +23,7 @@ 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"},
|
||||
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
recordListFieldRefFlag(),
|
||||
recordListViewRefFlag(),
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
|
||||
@@ -26,7 +26,6 @@ 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,25 +67,6 @@ 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"] != "" {
|
||||
@@ -195,9 +176,7 @@ var CalendarCreate = common.Shortcut{
|
||||
eventData := buildEventData(runtime, startTs, endTs)
|
||||
attendeesStr := runtime.Str("attendee-ids")
|
||||
if attendeesStr != "" {
|
||||
// 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.
|
||||
// Note: dry-run doesn't network resolve the current user's open_id.
|
||||
attendees, err := parseAttendees(attendeesStr, "")
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
@@ -249,8 +228,11 @@ var CalendarCreate = common.Shortcut{
|
||||
|
||||
// Add attendees if specified
|
||||
if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" {
|
||||
selfId := selfAttendeeId(runtime)
|
||||
attendees, err := parseAttendees(attendeesStr, selfId)
|
||||
currentUserId := ""
|
||||
if !runtime.IsBot() {
|
||||
currentUserId = runtime.UserOpenId()
|
||||
}
|
||||
attendees, err := parseAttendees(attendeesStr, currentUserId)
|
||||
if err != nil {
|
||||
return withParam(err, "--attendee-ids")
|
||||
}
|
||||
|
||||
@@ -1,790 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// calendar +update room-availability pre-check helpers.
|
||||
//
|
||||
// Uses /open-apis/calendar/v4/freebusy/room_availability_check to warn the
|
||||
// caller before an update either adds a new room attendee or shifts the time
|
||||
// of a slot that already has a room reservation. --skip-room-check bypasses
|
||||
// the check for callers that want to move fast.
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
flagSkipRoomCheck = "skip-room-check"
|
||||
roomCheckPath = "/open-apis/calendar/v4/freebusy/room_availability_check"
|
||||
)
|
||||
|
||||
// roomAvailability mirrors a single room result from the API.
|
||||
type roomAvailability struct {
|
||||
RoomID string `json:"room_id,omitempty"`
|
||||
RoomName string `json:"room_name,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
UnavailableReasonType string `json:"unavailable_reason_type,omitempty"`
|
||||
Strategy *roomStrategy `json:"room_strategy,omitempty"`
|
||||
Requisition *roomRequisition `json:"room_requisition,omitempty"`
|
||||
ApprovalInfo *roomApprovalInfo `json:"room_approval_info,omitempty"`
|
||||
}
|
||||
|
||||
// roomStrategy mirrors the room_strategy block returned by the API on
|
||||
// unavailable rooms. Every field is optional: the server only fills in the
|
||||
// entries relevant to the current unavailable_reason_type.
|
||||
type roomStrategy struct {
|
||||
SingleMaxDuration string `json:"single_max_duration,omitempty"`
|
||||
MaxAdvanceBookingTime string `json:"max_advance_booking_time,omitempty"`
|
||||
DailyStartTime string `json:"daily_start_time,omitempty"`
|
||||
DailyEndTime string `json:"daily_end_time,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
DailyAdvanceWindowReleaseTime string `json:"daily_advance_window_release_time,omitempty"`
|
||||
}
|
||||
|
||||
// roomRequisition mirrors room_requisition, returned by the API only when
|
||||
// unavailable_reason_type == "during_requisition". Both fields are RFC3339
|
||||
// strings and either may be empty if the server has no exact bound.
|
||||
type roomRequisition struct {
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
}
|
||||
|
||||
// roomApprovalInfo mirrors room_approval_info, returned when the room requires
|
||||
// (or may require) an approval submission before it can be booked.
|
||||
//
|
||||
// - ApprovalMode: "none" (no approval), "over_duration" (only when the
|
||||
// booking exceeds the threshold), or "all" (every booking needs approval).
|
||||
// - ApprovalDurationThreshold: seconds; only meaningful when
|
||||
// ApprovalMode == "over_duration". The server returns it as a numeric
|
||||
// string, matching the shape of the other duration fields.
|
||||
//
|
||||
// When the pre-check returns status == "need_approval" the caller renders a
|
||||
// friendly reminder derived from these two fields plus the current event
|
||||
// duration, so the agent knows whether to switch rooms/times or route the
|
||||
// user through an approval flow.
|
||||
type roomApprovalInfo struct {
|
||||
ApprovalMode string `json:"approval_mode,omitempty"`
|
||||
ApprovalDurationThreshold string `json:"approval_duration_threshold,omitempty"`
|
||||
}
|
||||
|
||||
// eventSnapshot carries only the fields room-check needs from the current
|
||||
// event: existing room IDs, current start/end (unix seconds string), timezone,
|
||||
// and rrule.
|
||||
type eventSnapshot struct {
|
||||
RoomIDs []string
|
||||
StartTs string
|
||||
EndTs string
|
||||
Timezone string
|
||||
Recurrent string
|
||||
}
|
||||
|
||||
// unavailableReasonHint maps API-declared unavailable reasons to a short
|
||||
// English phrase suitable for embedding in the block message. Unknown or
|
||||
// future reasons fall back to a single stable phrase so the CLI's blocked
|
||||
// message stays predictable for agents that parse it.
|
||||
func unavailableReasonHint(reason string) string {
|
||||
switch reason {
|
||||
case "reserved_by_other_event":
|
||||
return "already reserved by another event"
|
||||
case "past_time":
|
||||
return "cannot book a room in the past"
|
||||
case "beyond_advance_booking_window":
|
||||
return "beyond the room's advance-booking window"
|
||||
case "over_max_duration":
|
||||
return "exceeds the room's max single-booking duration"
|
||||
case "not_in_usable_time":
|
||||
return "outside the room's daily bookable window"
|
||||
case "during_requisition":
|
||||
return "the room is disabled during this time and cannot be booked"
|
||||
case "before_daily_advance_window_release":
|
||||
return "the target date is outside the room's currently unlocked advance-booking window; the window extends by one calendar day at the daily release time"
|
||||
case "recurring_exceed_approval_limit":
|
||||
return "recurring event duration exceeds the limit for booking this approval-required room — shorten the duration or pick a different room"
|
||||
default:
|
||||
return "currently unbookable"
|
||||
}
|
||||
}
|
||||
|
||||
// strategyDetail renders the human-readable suffix appended to the reason
|
||||
// phrase for a given (reason, strategy) pair. It returns an empty string when
|
||||
// no strategy data is available or when the fields relevant to this reason
|
||||
// are missing / invalid, so callers can safely concatenate the result.
|
||||
func strategyDetail(reason string, s *roomStrategy) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
switch reason {
|
||||
case "over_max_duration":
|
||||
if d := formatDurationSeconds(s.SingleMaxDuration); d != "" {
|
||||
return "the max single-booking duration is " + d
|
||||
}
|
||||
case "beyond_advance_booking_window":
|
||||
// The API returns max_advance_booking_time as RFC3339 already;
|
||||
// surface it verbatim so agents don't lose the exact instant.
|
||||
if t := strings.TrimSpace(s.MaxAdvanceBookingTime); t != "" {
|
||||
return "the latest bookable end time is " + t
|
||||
}
|
||||
case "not_in_usable_time":
|
||||
start := formatDaySeconds(s.DailyStartTime)
|
||||
end := formatDaySeconds(s.DailyEndTime)
|
||||
zone := roomZoneLabel(s.Timezone)
|
||||
switch {
|
||||
case start != "" && end != "":
|
||||
return fmt.Sprintf("the daily bookable window is %s - %s (%s)", start, end, zone)
|
||||
case start != "":
|
||||
return fmt.Sprintf("the daily bookable window starts at %s (%s)", start, zone)
|
||||
case end != "":
|
||||
return fmt.Sprintf("the daily bookable window ends at %s (%s)", end, zone)
|
||||
}
|
||||
case "before_daily_advance_window_release":
|
||||
if t := formatDaySeconds(s.DailyAdvanceWindowReleaseTime); t != "" {
|
||||
return fmt.Sprintf("the next unlock happens today at %s (%s), which advances the window by one day", t, roomZoneLabel(s.Timezone))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// requisitionDetail renders the suffix describing the room's scheduled
|
||||
// disable window for a `during_requisition` block. The API sends both bounds
|
||||
// as RFC3339 already, so we surface them verbatim to keep the exact instant.
|
||||
// Returns "" when both bounds are missing so the caller falls back to the
|
||||
// generic "pick a different time or a different room" recovery hint.
|
||||
func requisitionDetail(reason string, r *roomRequisition) string {
|
||||
if reason != "during_requisition" || r == nil {
|
||||
return ""
|
||||
}
|
||||
start := strings.TrimSpace(r.StartTime)
|
||||
end := strings.TrimSpace(r.EndTime)
|
||||
switch {
|
||||
case start != "" && end != "":
|
||||
return fmt.Sprintf("the disabled period is %s to %s", start, end)
|
||||
case start != "":
|
||||
return "the disabled period starts at " + start
|
||||
case end != "":
|
||||
return "the disabled period ends at " + end
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatDurationSeconds renders a whole-second string like "10800" as a
|
||||
// compact "H hours [M minutes]" phrase. Returns "" when the value is
|
||||
// missing, non-numeric, or non-positive.
|
||||
func formatDurationSeconds(raw string) string {
|
||||
sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || sec <= 0 {
|
||||
return ""
|
||||
}
|
||||
d := time.Duration(sec) * time.Second
|
||||
h := int(d / time.Hour)
|
||||
m := int((d % time.Hour) / time.Minute)
|
||||
switch {
|
||||
case h > 0 && m > 0:
|
||||
return fmt.Sprintf("%d hours %d minutes", h, m)
|
||||
case h > 0:
|
||||
return fmt.Sprintf("%d hours", h)
|
||||
case m > 0:
|
||||
return fmt.Sprintf("%d minutes", m)
|
||||
default:
|
||||
return fmt.Sprintf("%d seconds", sec)
|
||||
}
|
||||
}
|
||||
|
||||
// formatDaySeconds renders a "seconds since midnight" string as "HH:MM".
|
||||
// Returns "" when raw is missing, non-numeric, or outside [0, 24h). Seconds
|
||||
// are truncated because the API only guarantees minute-level meaning for
|
||||
// daily windows and release times.
|
||||
func formatDaySeconds(raw string) string {
|
||||
sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || sec < 0 || sec >= 24*3600 {
|
||||
return ""
|
||||
}
|
||||
h := sec / 3600
|
||||
m := (sec % 3600) / 60
|
||||
return fmt.Sprintf("%02d:%02d", h, m)
|
||||
}
|
||||
|
||||
// roomZoneLabel renders the room's timezone as either a "GMT±X" string
|
||||
// anchored to today (so DST is respected) when the IANA name resolves, or
|
||||
// the IANA name itself as a fallback so agents always see the source of
|
||||
// truth. Returns the local device timezone's label when raw is empty.
|
||||
func roomZoneLabel(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return gmtOffsetLabel(time.Now())
|
||||
}
|
||||
loc, err := time.LoadLocation(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return gmtOffsetLabel(time.Now().In(loc))
|
||||
}
|
||||
|
||||
// gmtOffsetLabel formats t's zone offset as "GMT+8" / "GMT-5:30" / "GMT".
|
||||
// Minute-precision is included only when the offset has a non-zero minute
|
||||
// component so the common whole-hour case stays terse.
|
||||
func gmtOffsetLabel(t time.Time) string {
|
||||
_, offsetSec := t.Zone()
|
||||
if offsetSec == 0 {
|
||||
return "GMT"
|
||||
}
|
||||
sign := "+"
|
||||
if offsetSec < 0 {
|
||||
sign = "-"
|
||||
offsetSec = -offsetSec
|
||||
}
|
||||
h := offsetSec / 3600
|
||||
m := (offsetSec % 3600) / 60
|
||||
if m == 0 {
|
||||
return fmt.Sprintf("GMT%s%d", sign, h)
|
||||
}
|
||||
return fmt.Sprintf("GMT%s%d:%02d", sign, h, m)
|
||||
}
|
||||
|
||||
// collectAttendeeRoomIDs extracts omm_ prefixed IDs from a comma-separated
|
||||
// flag value. Empty / whitespace input returns nil.
|
||||
func collectAttendeeRoomIDs(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var rooms []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
id := strings.TrimSpace(part)
|
||||
if !strings.HasPrefix(id, "omm_") {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
rooms = append(rooms, id)
|
||||
}
|
||||
return rooms
|
||||
}
|
||||
|
||||
// fetchEventSnapshot GETs the event with attendees so we can read the current
|
||||
// start / end / recurrence and the room IDs already booked on the event. It is
|
||||
// best-effort: any error bubbles up so the caller can降级放行 by warning.
|
||||
//
|
||||
// One retry is baked in: a `{uid}_{original_time}` event_id refers to a
|
||||
// specific instance of a recurring series, but until that instance is edited
|
||||
// and materialised as an exception, the server only knows the master
|
||||
// (`{uid}_0`) and answers 193001 (event not found). We detect that shape and
|
||||
// re-issue the GET against the master so the room-check pipeline still has a
|
||||
// snapshot to work with.
|
||||
func fetchEventSnapshot(_ context.Context, runtime *common.RuntimeContext, calendarID, eventID string) (*eventSnapshot, error) {
|
||||
data, err := callEventGet(runtime, calendarID, eventID)
|
||||
if err != nil {
|
||||
if masterID, ok := recurringMasterEventID(eventID); ok && isEventNotFound(err) {
|
||||
data, err = callEventGet(runtime, calendarID, masterID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
event, _ := data["event"].(map[string]interface{})
|
||||
if event == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
|
||||
}
|
||||
snap := &eventSnapshot{}
|
||||
if start, _ := event["start_time"].(map[string]interface{}); start != nil {
|
||||
if ts, _ := start["timestamp"].(string); ts != "" {
|
||||
snap.StartTs = ts
|
||||
}
|
||||
if tz, _ := start["timezone"].(string); tz != "" {
|
||||
snap.Timezone = tz
|
||||
}
|
||||
}
|
||||
if end, _ := event["end_time"].(map[string]interface{}); end != nil {
|
||||
if ts, _ := end["timestamp"].(string); ts != "" {
|
||||
snap.EndTs = ts
|
||||
}
|
||||
if snap.Timezone == "" {
|
||||
if tz, _ := end["timezone"].(string); tz != "" {
|
||||
snap.Timezone = tz
|
||||
}
|
||||
}
|
||||
}
|
||||
if r, _ := event["recurrence"].(string); r != "" {
|
||||
snap.Recurrent = r
|
||||
}
|
||||
attendees, _ := event["attendees"].([]interface{})
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range attendees {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if t, _ := m["type"].(string); t != "resource" {
|
||||
continue
|
||||
}
|
||||
id, _ := m["room_id"].(string)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if status, _ := m["rsvp_status"].(string); status == "removed" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
snap.RoomIDs = append(snap.RoomIDs, id)
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// callEventGet issues the calendar event GET used by fetchEventSnapshot. It
|
||||
// is factored out so the 193001 fallback can re-issue the request against
|
||||
// the master event without duplicating the params / path plumbing.
|
||||
func callEventGet(runtime *common.RuntimeContext, calendarID, eventID string) (map[string]interface{}, error) {
|
||||
path := fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
|
||||
validate.EncodePathSegment(calendarID), validate.EncodePathSegment(eventID))
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": "open_id",
|
||||
"need_attendee": true,
|
||||
"max_attendee_num": 20,
|
||||
}
|
||||
return runtime.CallAPITyped("GET", path, params, nil)
|
||||
}
|
||||
|
||||
// recurringMasterEventID inspects a calendar event_id shaped like
|
||||
// `{uid}_{original_time}` and returns `{uid}_0` when original_time is a
|
||||
// positive integer, plus true so callers know a fallback is worth trying.
|
||||
// Any other shape (missing underscore, non-numeric suffix, already `_0`, or
|
||||
// suffix `0` / negative) returns "", false so we don't retry pointlessly.
|
||||
func recurringMasterEventID(eventID string) (string, bool) {
|
||||
idx := strings.LastIndex(eventID, "_")
|
||||
if idx <= 0 || idx == len(eventID)-1 {
|
||||
return "", false
|
||||
}
|
||||
uid := eventID[:idx]
|
||||
suffix := eventID[idx+1:]
|
||||
n, err := strconv.ParseInt(suffix, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return uid + "_0", true
|
||||
}
|
||||
|
||||
// isEventNotFound returns true when err is a calendar 193001 (event not
|
||||
// found) API error. Kept in this file rather than shared with
|
||||
// unwrapCalendarAPIError because that helper returns a user-facing hint —
|
||||
// here we only need the classification, not the copy.
|
||||
func isEventNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var ae *errs.APIError
|
||||
if !errors.As(err, &ae) {
|
||||
return false
|
||||
}
|
||||
return ae.Code == 193001
|
||||
}
|
||||
|
||||
// roomCheckPlan bundles the resolved inputs for the pre-check API call.
|
||||
type roomCheckPlan struct {
|
||||
RoomIDs []string
|
||||
StartTs string
|
||||
EndTs string
|
||||
StartTimezone string
|
||||
Rrule string
|
||||
}
|
||||
|
||||
// resolveRoomCheckPlan works out which rooms to check and the target time
|
||||
// window. It applies the降级放行 policy: if the event snapshot fails to load
|
||||
// but we can proceed with only user-provided inputs (i.e., time changed and a
|
||||
// new room is added), the pre-check still runs against those. Otherwise it
|
||||
// warns and returns (nil, nil) so the caller skips the check.
|
||||
//
|
||||
// Returns (nil, nil) when no check is warranted.
|
||||
func resolveRoomCheckPlan(ctx context.Context, runtime *common.RuntimeContext, calendarID, eventID string, newStartTs, newEndTs string, timeChanged, rruleChanged bool) (*roomCheckPlan, error) {
|
||||
newRooms := collectAttendeeRoomIDs(runtime.Str("add-attendee-ids"))
|
||||
removeSet := map[string]struct{}{}
|
||||
for _, id := range collectAttendeeRoomIDs(runtime.Str("remove-attendee-ids")) {
|
||||
removeSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
// Fast path: only trigger the check when it can find something to look at.
|
||||
// - New room attendees → always check.
|
||||
// - Time or rrule change → check existing rooms if any.
|
||||
if len(newRooms) == 0 && !timeChanged && !rruleChanged {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
newRrule := strings.TrimSpace(runtime.Str("rrule"))
|
||||
|
||||
// If we don't need existing rooms and have both start/end, skip the GET.
|
||||
needSnapshot := timeChanged || rruleChanged || !timeChanged && len(newRooms) > 0
|
||||
|
||||
var snap *eventSnapshot
|
||||
if needSnapshot {
|
||||
var err error
|
||||
snap, err = fetchEventSnapshot(ctx, runtime, calendarID, eventID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[calendar +update] warning: failed to fetch current event for room-availability check (%v); precheck runs only against user-supplied inputs — pass --%s to silence\n",
|
||||
err, flagSkipRoomCheck)
|
||||
snap = nil
|
||||
}
|
||||
}
|
||||
|
||||
plan := &roomCheckPlan{
|
||||
StartTs: newStartTs,
|
||||
EndTs: newEndTs,
|
||||
Rrule: newRrule,
|
||||
}
|
||||
if plan.StartTs == "" && snap != nil {
|
||||
plan.StartTs = snap.StartTs
|
||||
}
|
||||
if plan.EndTs == "" && snap != nil {
|
||||
plan.EndTs = snap.EndTs
|
||||
}
|
||||
if plan.Rrule == "" && snap != nil {
|
||||
plan.Rrule = snap.Recurrent
|
||||
}
|
||||
if snap != nil {
|
||||
plan.StartTimezone = snap.Timezone
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
addRoom := func(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := removeSet[id]; ok {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
plan.RoomIDs = append(plan.RoomIDs, id)
|
||||
}
|
||||
for _, id := range newRooms {
|
||||
addRoom(id)
|
||||
}
|
||||
if snap != nil && (timeChanged || rruleChanged) {
|
||||
for _, id := range snap.RoomIDs {
|
||||
addRoom(id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(plan.RoomIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Without a target window the server has no basis to check anything;
|
||||
// prefer degrading gracefully to blocking legitimate updates.
|
||||
if plan.StartTs == "" || plan.EndTs == "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[calendar +update] warning: room-availability check skipped because start/end could not be resolved; pass --%s to silence\n",
|
||||
flagSkipRoomCheck)
|
||||
return nil, nil
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// roomCheckPlanDurationSec returns the current booking duration in whole
|
||||
// seconds derived from the resolved plan's Unix-second window, or 0 when
|
||||
// either bound is missing or unparseable. Used to compare against
|
||||
// approval_duration_threshold when the API asks for approval.
|
||||
func roomCheckPlanDurationSec(plan *roomCheckPlan) int64 {
|
||||
if plan == nil {
|
||||
return 0
|
||||
}
|
||||
start, err := strconv.ParseInt(strings.TrimSpace(plan.StartTs), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
end, err := strconv.ParseInt(strings.TrimSpace(plan.EndTs), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if end <= start {
|
||||
return 0
|
||||
}
|
||||
return end - start
|
||||
}
|
||||
|
||||
// buildRoomCheckBody assembles the request body for room_availability_check.
|
||||
// The pre-check API expects start/end as RFC3339 timestamps; we take the
|
||||
// Unix-second strings used elsewhere in the update flow and render them in
|
||||
// the event's own timezone when available, falling back to the local device
|
||||
// timezone so agents on different machines still produce a valid request.
|
||||
// start_timezone is an IANA name (e.g. "Asia/Shanghai") copied from the event
|
||||
// snapshot; it is omitted when unknown so the server can fall back to its own
|
||||
// default.
|
||||
func buildRoomCheckBody(calendarID, eventID string, plan *roomCheckPlan) map[string]interface{} {
|
||||
loc := time.Local
|
||||
if plan.StartTimezone != "" {
|
||||
if l, err := time.LoadLocation(plan.StartTimezone); err == nil {
|
||||
loc = l
|
||||
}
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"calendar_id": calendarID,
|
||||
"event_id": eventID,
|
||||
"start_time": formatRoomCheckTime(plan.StartTs, loc),
|
||||
"end_time": formatRoomCheckTime(plan.EndTs, loc),
|
||||
"room_ids": plan.RoomIDs,
|
||||
}
|
||||
if plan.StartTimezone != "" {
|
||||
body["start_timezone"] = plan.StartTimezone
|
||||
}
|
||||
if plan.Rrule != "" {
|
||||
body["event_rrule"] = plan.Rrule
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// formatRoomCheckTime renders a Unix-second string as RFC3339 in loc.
|
||||
// Non-numeric input is returned unchanged so anomalies stay visible instead
|
||||
// of being silently rewritten to the epoch.
|
||||
func formatRoomCheckTime(unixStr string, loc *time.Location) string {
|
||||
sec, err := strconv.ParseInt(strings.TrimSpace(unixStr), 10, 64)
|
||||
if err != nil {
|
||||
return unixStr
|
||||
}
|
||||
return time.Unix(sec, 0).In(loc).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// callRoomAvailabilityCheck posts the availability request and returns per-room
|
||||
// results.
|
||||
func callRoomAvailabilityCheck(runtime *common.RuntimeContext, body map[string]interface{}) ([]roomAvailability, error) {
|
||||
data, err := runtime.CallAPITyped("POST", roomCheckPath, nil, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawList, _ := data["room_availabilitys"].([]interface{})
|
||||
out := make([]roomAvailability, 0, len(rawList))
|
||||
for _, raw := range rawList {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := roomAvailability{}
|
||||
if v, ok := m["room_id"].(string); ok {
|
||||
item.RoomID = v
|
||||
}
|
||||
if v, ok := m["room_name"].(string); ok {
|
||||
item.RoomName = v
|
||||
}
|
||||
if v, ok := m["status"].(string); ok {
|
||||
item.Status = v
|
||||
}
|
||||
if v, ok := m["unavailable_reason_type"].(string); ok {
|
||||
item.UnavailableReasonType = v
|
||||
}
|
||||
if strat, ok := m["room_strategy"].(map[string]interface{}); ok {
|
||||
item.Strategy = parseRoomStrategy(strat)
|
||||
}
|
||||
if req, ok := m["room_requisition"].(map[string]interface{}); ok {
|
||||
item.Requisition = parseRoomRequisition(req)
|
||||
}
|
||||
if info, ok := m["room_approval_info"].(map[string]interface{}); ok {
|
||||
item.ApprovalInfo = parseRoomApprovalInfo(info)
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseRoomStrategy extracts the optional strategy fields from a raw API
|
||||
// map. Missing / non-string values are dropped so callers only see what the
|
||||
// server actually sent.
|
||||
func parseRoomStrategy(m map[string]interface{}) *roomStrategy {
|
||||
s := &roomStrategy{}
|
||||
if v, ok := m["single_max_duration"].(string); ok {
|
||||
s.SingleMaxDuration = v
|
||||
}
|
||||
if v, ok := m["max_advance_booking_time"].(string); ok {
|
||||
s.MaxAdvanceBookingTime = v
|
||||
}
|
||||
if v, ok := m["daily_start_time"].(string); ok {
|
||||
s.DailyStartTime = v
|
||||
}
|
||||
if v, ok := m["daily_end_time"].(string); ok {
|
||||
s.DailyEndTime = v
|
||||
}
|
||||
if v, ok := m["timezone"].(string); ok {
|
||||
s.Timezone = v
|
||||
}
|
||||
if v, ok := m["daily_advance_window_release_time"].(string); ok {
|
||||
s.DailyAdvanceWindowReleaseTime = v
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// parseRoomRequisition extracts the optional room_requisition block from a
|
||||
// raw API map. Missing / non-string values are dropped.
|
||||
func parseRoomRequisition(m map[string]interface{}) *roomRequisition {
|
||||
r := &roomRequisition{}
|
||||
if v, ok := m["start_time"].(string); ok {
|
||||
r.StartTime = v
|
||||
}
|
||||
if v, ok := m["end_time"].(string); ok {
|
||||
r.EndTime = v
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// parseRoomApprovalInfo extracts the optional room_approval_info block from a
|
||||
// raw API map. Missing / non-string values are dropped.
|
||||
func parseRoomApprovalInfo(m map[string]interface{}) *roomApprovalInfo {
|
||||
info := &roomApprovalInfo{}
|
||||
if v, ok := m["approval_mode"].(string); ok {
|
||||
info.ApprovalMode = v
|
||||
}
|
||||
if v, ok := m["approval_duration_threshold"].(string); ok {
|
||||
info.ApprovalDurationThreshold = v
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// approvalReasonHint composes the per-line phrase for a `need_approval`
|
||||
// status. The API returns `room_approval_info` with:
|
||||
//
|
||||
// - "all" → every reservation on this room must be approved.
|
||||
// - "over_duration" → only bookings longer than approval_duration_threshold
|
||||
// need approval. The current event duration (eventDurationSec) is compared
|
||||
// against the threshold so agents can see exactly why approval is being
|
||||
// asked for — and, when the current duration is below the threshold, the
|
||||
// message points at the "shorten it" recovery path.
|
||||
// - anything else → generic reminder so unknown modes still surface.
|
||||
//
|
||||
// This function only produces the per-room fragment. The shared recovery
|
||||
// clause (attendees-create, client fallback, shorten, pick another room) is
|
||||
// appended once by blockOnUnavailableRooms into `.WithHint(...)` so a message
|
||||
// with several approval-required rooms doesn't repeat the same recovery
|
||||
// paragraph on every line.
|
||||
func approvalReasonHint(info *roomApprovalInfo, eventDurationSec int64) string {
|
||||
mode := ""
|
||||
if info != nil {
|
||||
mode = strings.TrimSpace(info.ApprovalMode)
|
||||
}
|
||||
switch mode {
|
||||
case "all":
|
||||
return "this room requires approval for every reservation"
|
||||
case "over_duration":
|
||||
threshold, _ := strconv.ParseInt(strings.TrimSpace(info.ApprovalDurationThreshold), 10, 64)
|
||||
if threshold <= 0 {
|
||||
// Server said approval-by-duration but didn't give a threshold —
|
||||
// keep the mode label so agents don't lose the classification.
|
||||
return "this room requires approval when the booking exceeds a duration threshold"
|
||||
}
|
||||
thresholdPhrase := formatDurationSeconds(info.ApprovalDurationThreshold)
|
||||
if thresholdPhrase == "" {
|
||||
thresholdPhrase = fmt.Sprintf("%d seconds", threshold)
|
||||
}
|
||||
base := fmt.Sprintf("this room requires approval when the booking exceeds %s", thresholdPhrase)
|
||||
if eventDurationSec > 0 {
|
||||
currentPhrase := formatDurationSeconds(strconv.FormatInt(eventDurationSec, 10))
|
||||
if currentPhrase == "" {
|
||||
currentPhrase = fmt.Sprintf("%d seconds", eventDurationSec)
|
||||
}
|
||||
if eventDurationSec >= threshold {
|
||||
base += fmt.Sprintf(" (current duration is %s)", currentPhrase)
|
||||
} else {
|
||||
// Server flagged approval but our duration reads as below the
|
||||
// threshold — surface both so the agent can reconcile rather
|
||||
// than guess.
|
||||
base += fmt.Sprintf(" (current duration reads as %s; server still flagged approval)", currentPhrase)
|
||||
}
|
||||
}
|
||||
return base
|
||||
default:
|
||||
return "this room requires approval before it can be booked"
|
||||
}
|
||||
}
|
||||
|
||||
// roomLabel renders the room identifier for the block message. When the API
|
||||
// returns a human-readable name it becomes `<room_id>[<room_name>]`; a blank
|
||||
// name (or an entirely blank id, defensive) degrades to whichever is present
|
||||
// so agents can still address the room. The room_id is kept as the primary
|
||||
// identifier because callers act on it programmatically. Square brackets are
|
||||
// used (rather than parentheses) so a room name that itself contains
|
||||
// parentheses — e.g. "Room A (west wing)" — doesn't produce ambiguous nesting
|
||||
// like `omm_1(Room A (west wing))`.
|
||||
func roomLabel(id, name string) string {
|
||||
id = strings.TrimSpace(id)
|
||||
name = strings.TrimSpace(name)
|
||||
switch {
|
||||
case id != "" && name != "":
|
||||
return fmt.Sprintf("%s[%s]", id, name)
|
||||
case id != "":
|
||||
return id
|
||||
default:
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// blockOnUnavailableRooms returns a typed validation error when any room in
|
||||
// results is unavailable or requires approval, or nil when everything is
|
||||
// bookable. The error text carries per-room reasons plus the retry command
|
||||
// hint from the PRD. When the API returns a room_strategy for a blocked room,
|
||||
// the relevant limit (max duration, latest bookable time, daily window, or
|
||||
// daily release time) is appended after the reason so agents can relay it to
|
||||
// the user without making a follow-up request. For a `during_requisition`
|
||||
// block, the disabled period (from room_requisition) is appended if available;
|
||||
// a "pick a different time or a different room" recovery clause is always
|
||||
// appended so the message reads coherently whether or not exact bounds are
|
||||
// known.
|
||||
//
|
||||
// `need_approval` results are treated as blocking (the CLI cannot submit an
|
||||
// approval on the user's behalf, so silently PATCHing would surprise the
|
||||
// user). The line uses room_approval_info + eventDurationSec to explain the
|
||||
// mode ("all" / "over_duration"), the threshold, and — for over_duration —
|
||||
// how the current booking compares. The shared "how do I actually recover
|
||||
// from approval" clause is folded into the hint once (not per line), so
|
||||
// several approval-required rooms don't repeat the same paragraph.
|
||||
func blockOnUnavailableRooms(results []roomAvailability, eventDurationSec int64) error {
|
||||
var blocked []roomAvailability
|
||||
for _, r := range results {
|
||||
if r.Status != "available" {
|
||||
blocked = append(blocked, r)
|
||||
}
|
||||
}
|
||||
if len(blocked) == 0 {
|
||||
return nil
|
||||
}
|
||||
var lines []string
|
||||
hasNeedApproval := false
|
||||
for _, r := range blocked {
|
||||
var reason string
|
||||
switch r.Status {
|
||||
case "need_approval":
|
||||
hasNeedApproval = true
|
||||
reason = approvalReasonHint(r.ApprovalInfo, eventDurationSec)
|
||||
default:
|
||||
reason = unavailableReasonHint(r.UnavailableReasonType)
|
||||
}
|
||||
line := fmt.Sprintf("%s: %s", roomLabel(r.RoomID, r.RoomName), reason)
|
||||
if detail := strategyDetail(r.UnavailableReasonType, r.Strategy); detail != "" {
|
||||
line += ", " + detail
|
||||
}
|
||||
if detail := requisitionDetail(r.UnavailableReasonType, r.Requisition); detail != "" {
|
||||
line += ", " + detail
|
||||
}
|
||||
if r.UnavailableReasonType == "during_requisition" {
|
||||
line += "; pick a different time or a different room"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
msg := "meeting room booking will fail after this event change:\n " + strings.Join(lines, "\n ")
|
||||
hint := fmt.Sprintf("do NOT auto-retry: relay the room IDs and reasons above to the user and get explicit confirmation before re-running with --%s.",
|
||||
flagSkipRoomCheck)
|
||||
if hasNeedApproval {
|
||||
hint += " Rooms flagged need_approval: the CLI cannot submit approvals; DO NOT auto-run any recovery — ask the user first, then pick one: (a) newly added room → after the user confirms and provides `approval_reason`, run `lark-cli calendar event.attendees create --as user`; (b) time/rrule change re-triggers approval on an existing room → ask the user to update through the client; (c) shorten the meeting below the threshold or pick a different room."
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", msg).WithHint("%s", hint)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user