mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +08:00
Compare commits
20 Commits
feat/miaod
...
feat/apps-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4c313c8f1 | ||
|
|
a2c820643d | ||
|
|
8e60f01474 | ||
|
|
465c789f7c | ||
|
|
2a7e9c7d0d | ||
|
|
76ba6fad4f | ||
|
|
510545f1e5 | ||
|
|
c11cf3b716 | ||
|
|
ee2c93efeb | ||
|
|
33e459a4de | ||
|
|
5aeae2db65 | ||
|
|
9b39d10203 | ||
|
|
8572a58fda | ||
|
|
9bc66cc445 | ||
|
|
e53f9d999e | ||
|
|
ae35b35693 | ||
|
|
c2e617fc96 | ||
|
|
3f77eded9d | ||
|
|
e64610f6d2 | ||
|
|
dfa26c38f6 |
30
.github/CODEOWNERS
vendored
Normal file
30
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
/internal/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
/skills/lark-approval/
|
||||
/skills/lark-apps/
|
||||
/skills/lark-attendance/
|
||||
/skills/lark-base/
|
||||
/skills/lark-calendar/
|
||||
/skills/lark-contact/
|
||||
/skills/lark-doc/
|
||||
/skills/lark-drive/
|
||||
/skills/lark-event/
|
||||
/skills/lark-im/
|
||||
/skills/lark-mail/
|
||||
/skills/lark-markdown/
|
||||
/skills/lark-minutes/
|
||||
/skills/lark-okr/
|
||||
/skills/lark-openapi-explorer/
|
||||
/skills/lark-shared/
|
||||
/skills/lark-sheets/
|
||||
/skills/lark-skill-maker/
|
||||
/skills/lark-slides/
|
||||
/skills/lark-task/
|
||||
/skills/lark-vc/
|
||||
/skills/lark-vc-agent/
|
||||
/skills/lark-whiteboard/
|
||||
/skills/lark-wiki/
|
||||
/skills/lark-workflow-meeting-summary/
|
||||
/skills/lark-workflow-standup-report/
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,8 @@ tests/mail/reports/
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
/notes/
|
||||
/minutes/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
|
||||
@@ -73,20 +73,20 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
@@ -17,6 +17,7 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
|
||||
archives:
|
||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -2,6 +2,30 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.52] - 2026-06-11
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Per-resource subscription identity + Match hook (#1185)
|
||||
- **apps**: Emit typed error envelopes across the apps domain (#1288)
|
||||
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
|
||||
- **im**: Add `--chat-modes` filter to chat search (#1317)
|
||||
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
|
||||
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Support git credential dry-run (#1390)
|
||||
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
|
||||
- **build**: Make `-race` flag arch-conditional to support riscv64
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
|
||||
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
|
||||
- **skills**: Optimize lark-drive skill routing (#1284)
|
||||
- **skills**: Expand cite user guidance and fix typos (#1394)
|
||||
|
||||
## [v1.0.51] - 2026-06-10
|
||||
|
||||
### Features
|
||||
@@ -1106,6 +1130,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||
|
||||
9
Makefile
9
Makefile
@@ -8,6 +8,13 @@ DATE := $(shell date +%Y-%m-%d)
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
|
||||
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
|
||||
# over `go env GOARCH`, because command-line make variables are not visible to
|
||||
# $(shell ...).
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
||||
|
||||
all: test
|
||||
@@ -34,7 +41,7 @@ fmt-check:
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
go test -race -gcflags="all=-N -l" -count=1 \
|
||||
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
|
||||
@@ -91,6 +91,29 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *CheckOptions
|
||||
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -109,6 +132,27 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *LogoutOptions
|
||||
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -126,6 +170,27 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ListOptions
|
||||
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -145,6 +210,29 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -267,6 +355,32 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *ScopesOptions
|
||||
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--format", "pretty", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
if gotOpts.Format != "json" {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
type CheckOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Scope string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthCheck creates the auth check subcommand.
|
||||
@@ -37,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.MarkFlagRequired("scope")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
// ListOptions holds all inputs for auth list.
|
||||
type ListOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthList creates the auth list subcommand.
|
||||
@@ -34,6 +35,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
return authListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
@@ -44,6 +46,14 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// auth list is a read-only probe; the "configured but no users"
|
||||
// branch below already returns exit 0 with a stderr hint, so we
|
||||
// keep the same contract here. We still want the hint to be
|
||||
@@ -61,6 +71,14 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||
@@ -57,3 +85,48 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
|
||||
t.Errorf("agent hint must not mention config init: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "No logged-in users") {
|
||||
t.Errorf("stderr = %q, want no-users hint", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
// LogoutOptions holds all inputs for auth logout.
|
||||
type LogoutOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthLogout creates the auth logout subcommand.
|
||||
@@ -34,6 +35,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
return authLogoutRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
@@ -44,12 +46,28 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||
return nil
|
||||
}
|
||||
@@ -63,6 +81,13 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||
return nil
|
||||
}
|
||||
|
||||
147
cmd/auth/logout_test.go
Normal file
147
cmd/auth/logout_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
|
||||
t.Helper()
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "test-app",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: users,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != true {
|
||||
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
|
||||
}
|
||||
if _, hasReason := payload["reason"]; hasReason {
|
||||
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Logged out") {
|
||||
t.Errorf("stderr = %q, want success text", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type ScopesOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
Format string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthScopes creates the auth scopes subcommand.
|
||||
@@ -30,6 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
Short: "Query scopes enabled for the app",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
if opts.JSON {
|
||||
opts.Format = "json"
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -38,6 +42,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
type StatusOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Verify bool
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthStatus creates the auth status subcommand.
|
||||
@@ -35,6 +36,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -143,6 +143,79 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 2,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
|
||||
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB column header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "alice") {
|
||||
t.Errorf("missing alice suffix in SUB column: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "bob") {
|
||||
t.Errorf("missing bob suffix in SUB column: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStatusJSON(&buf, []appStatus{
|
||||
|
||||
@@ -134,12 +134,16 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if len(def.Params) > 0 {
|
||||
fmt.Fprintf(out, "\nParameters:\n")
|
||||
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n")
|
||||
for _, p := range def.Params {
|
||||
required := "no"
|
||||
if p.Required {
|
||||
required = "yes"
|
||||
}
|
||||
subKey := "no"
|
||||
if p.SubscriptionKey {
|
||||
subKey = "yes"
|
||||
}
|
||||
defaultVal := p.Default
|
||||
if defaultVal == "" {
|
||||
defaultVal = "-"
|
||||
@@ -148,7 +152,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
|
||||
@@ -96,6 +96,79 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{
|
||||
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
|
||||
{Name: "folders", Description: "filter only"},
|
||||
},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "SUB-KEY") {
|
||||
t.Errorf("missing SUB-KEY column header in:\n%s", out)
|
||||
}
|
||||
|
||||
// Find the mailbox row and verify "yes" is present
|
||||
var mailboxRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
|
||||
mailboxRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(mailboxRow, "yes") {
|
||||
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
|
||||
}
|
||||
|
||||
// Find the folders row and verify "no" is present
|
||||
var foldersRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
|
||||
foldersRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(foldersRow, "no") {
|
||||
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||
const syntheticKey = "test.evt_json"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), `"subscription_key"`) {
|
||||
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `true`) {
|
||||
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
const syntheticKey = "t.custom.overlay"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -242,12 +243,17 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
|
||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||
if len(s.Consumers) > 0 {
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"}
|
||||
rows := make([][]string, 0, len(s.Consumers))
|
||||
for _, c := range s.Consumers {
|
||||
subDisplay := "-"
|
||||
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
|
||||
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
fmt.Sprintf("pid=%d", c.PID),
|
||||
c.EventKey,
|
||||
subDisplay,
|
||||
fmt.Sprintf("%d", c.Received),
|
||||
fmt.Sprintf("%d", c.Dropped),
|
||||
})
|
||||
|
||||
@@ -80,6 +80,7 @@ const (
|
||||
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
|
||||
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
|
||||
SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read)
|
||||
SubtypeExternalTool Subtype = "external_tool" // an external tool the CLI shells out to (git, npx) failed at runtime; the tool output is in the message
|
||||
SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save)
|
||||
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
|
||||
)
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
@@ -25,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
return func() error {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
@@ -25,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
return func() error {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ const cleanupTimeout = 5 * time.Second
|
||||
//
|
||||
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
|
||||
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
@@ -44,10 +44,13 @@ func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, ev
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
return func() error {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,26 +18,17 @@ type IOStreams struct {
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
IsTerminal bool
|
||||
// ErrIsTerminal reports whether ErrOut is an interactive terminal. Use it to
|
||||
// gate stderr-only animations (spinners) so pipes / CI / captured stderr stay
|
||||
// clean. Derived from ErrOut's underlying *os.File; non-file writers → false.
|
||||
ErrIsTerminal bool
|
||||
}
|
||||
|
||||
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
|
||||
// IsTerminal is derived from in's underlying *os.File, if any; non-file
|
||||
// readers (bytes.Buffer, strings.Reader, …) yield IsTerminal=false.
|
||||
// ErrIsTerminal is derived the same way from errOut.
|
||||
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
|
||||
isTerminal := false
|
||||
if f, ok := in.(*os.File); ok {
|
||||
isTerminal = term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
errIsTerminal := false
|
||||
if f, ok := errOut.(*os.File); ok {
|
||||
errIsTerminal = term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, ErrIsTerminal: errIsTerminal}
|
||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
|
||||
}
|
||||
|
||||
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
||||
@@ -66,7 +57,6 @@ func normalizeStreams(s *IOStreams) *IOStreams {
|
||||
}
|
||||
if out.ErrOut == nil {
|
||||
out.ErrOut = sys.ErrOut
|
||||
out.ErrIsTerminal = sys.ErrIsTerminal
|
||||
}
|
||||
}
|
||||
return &out
|
||||
|
||||
@@ -262,19 +262,23 @@ func (b *Bus) handleConn(conn net.Conn) {
|
||||
|
||||
// handleHello registers a consume connection with the hub; reader carries bytes already pulled off conn.
|
||||
func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) {
|
||||
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID)
|
||||
subID := hello.SubscriptionID
|
||||
if subID == "" {
|
||||
subID = hello.EventKey
|
||||
}
|
||||
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID)
|
||||
bc.SetLogger(b.logger)
|
||||
|
||||
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
|
||||
firstForKey := b.hub.RegisterAndIsFirst(bc)
|
||||
|
||||
bc.SetCheckLastForKey(func(eventKey string) bool {
|
||||
return b.hub.AcquireCleanupLock(eventKey)
|
||||
bc.SetCheckLastForKey(func(scope string) bool {
|
||||
return b.hub.AcquireCleanupLock(scope)
|
||||
})
|
||||
bc.SetOnClose(func(c *Conn) {
|
||||
b.hub.UnregisterAndIsLast(c)
|
||||
// Release is idempotent and must fire on every disconnect path so waiters don't block forever.
|
||||
b.hub.ReleaseCleanupLock(c.EventKey())
|
||||
b.hub.ReleaseCleanupLock(c.SubscriptionID())
|
||||
b.mu.Lock()
|
||||
delete(b.conns, c)
|
||||
remaining := len(b.conns)
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestRunShutdownWithMultipleConns(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
pipes = append(pipes, server, client)
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i)
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i, "")
|
||||
bc.SetLogger(logger)
|
||||
hub.RegisterAndIsFirst(bc)
|
||||
|
||||
|
||||
@@ -29,9 +29,10 @@ type Conn struct {
|
||||
writeMu sync.Mutex // serialises all net.Conn writes (Encode+SetWriteDeadline is a 2-call sequence)
|
||||
eventKey string
|
||||
eventTypes []string
|
||||
subID string
|
||||
pid int
|
||||
onClose func(*Conn)
|
||||
checkLastForKey func(eventKey string) bool
|
||||
checkLastForKey func(scope string) bool
|
||||
logger *log.Logger
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
@@ -41,7 +42,7 @@ type Conn struct {
|
||||
}
|
||||
|
||||
// NewConn creates a Conn; pass a reader with pre-buffered bytes (handoff from Bus.handleConn) or nil for a fresh one.
|
||||
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int) *Conn {
|
||||
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int, subID string) *Conn {
|
||||
if reader == nil {
|
||||
reader = bufio.NewReader(conn)
|
||||
}
|
||||
@@ -52,10 +53,20 @@ func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []
|
||||
eventKey: eventKey,
|
||||
eventTypes: eventTypes,
|
||||
pid: pid,
|
||||
subID: subID,
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriptionID returns the subscription identity. Falls back to EventKey
|
||||
// when the stored subID is empty (legacy clients / no-SubscriptionKey EventKeys).
|
||||
func (c *Conn) SubscriptionID() string {
|
||||
if c.subID == "" {
|
||||
return c.eventKey
|
||||
}
|
||||
return c.subID
|
||||
}
|
||||
|
||||
func (c *Conn) SetOnClose(fn func(*Conn)) { c.onClose = fn }
|
||||
|
||||
// SetCheckLastForKey: returning true means "you are the last subscriber, run cleanup".
|
||||
@@ -132,13 +143,19 @@ func (c *Conn) ReaderLoop() {
|
||||
}
|
||||
|
||||
func (c *Conn) handleControlMessage(msg interface{}) {
|
||||
switch m := msg.(type) {
|
||||
switch msg.(type) {
|
||||
case *protocol.Bye:
|
||||
c.shutdown()
|
||||
case *protocol.PreShutdownCheck:
|
||||
// Use the connection's own authoritative subscription identity rather
|
||||
// than recomputing from the incoming message: a stale or mismatched
|
||||
// PreShutdownCheck must not ask about the wrong scope (which would
|
||||
// suppress or mistrigger per-subscription cleanup). Conn.SubscriptionID()
|
||||
// already falls back to EventKey when its stored subID is empty.
|
||||
scope := c.SubscriptionID()
|
||||
lastForKey := true
|
||||
if c.checkLastForKey != nil {
|
||||
lastForKey = c.checkLastForKey(m.EventKey)
|
||||
lastForKey = c.checkLastForKey(scope)
|
||||
}
|
||||
ack := protocol.NewPreShutdownAck(lastForKey)
|
||||
if err := c.writeFrame(ack); err != nil && c.logger != nil {
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestConn_SenderWritesEvents(t *testing.T) {
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345)
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345, "")
|
||||
go bc.SenderLoop()
|
||||
|
||||
bc.SendCh() <- &protocol.Event{
|
||||
@@ -62,7 +62,7 @@ func TestConn_ConcurrentWritesSerialised(t *testing.T) {
|
||||
defer client.Close()
|
||||
|
||||
det := &serializingDetector{Conn: server}
|
||||
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
|
||||
go func() { _, _ = io.Copy(io.Discard, client) }()
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestConn_TrySend_NonEvicting(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
|
||||
for i := 0; i < sendChCap; i++ {
|
||||
if !bc.TrySend(i) {
|
||||
@@ -126,7 +126,7 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -142,3 +142,23 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
|
||||
t.Fatal("ReaderLoop did not exit on EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_SubscriptionID(t *testing.T) {
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "mail.x:abc")
|
||||
if got := conn.SubscriptionID(); got != "mail.x:abc" {
|
||||
t.Errorf("SubscriptionID() = %q, want %q", got, "mail.x:abc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_SubscriptionID_EmptyFallsBackToEventKey(t *testing.T) {
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "")
|
||||
if got := conn.SubscriptionID(); got != "mail.x" {
|
||||
t.Errorf("SubscriptionID() with empty input = %q, want fallback %q", got, "mail.x")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,3 +63,134 @@ func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) {
|
||||
t.Errorf("b.conns after failed HelloAck = %d entries, want 0", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHello_LegacyClient_FallsBackToEventKey: a Hello with empty
|
||||
// subscription_id registers under EventKey (today's behavior preserved).
|
||||
func TestHandleHello_LegacyClient_FallsBackToEventKey(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
// Legacy client: no subscription_id field (empty string).
|
||||
hello := &protocol.Hello{
|
||||
PID: 9999,
|
||||
EventKey: "im.message",
|
||||
EventTypes: []string{"im.message.receive_v1"},
|
||||
SubscriptionID: "", // legacy: empty, should fallback to EventKey
|
||||
}
|
||||
|
||||
br := bufio.NewReader(server)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.handleHello(server, br, hello)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Read the HelloAck from client side to let handleHello complete.
|
||||
clientReader := bufio.NewReader(client)
|
||||
ackLine, err := clientReader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read HelloAck: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handleHello did not return within 3s")
|
||||
}
|
||||
|
||||
// Assertions: registered under EventKey (not a qualified subscription ID).
|
||||
if got := hub.ConnCount(); got != 1 {
|
||||
t.Errorf("hub.ConnCount = %d, want 1", got)
|
||||
}
|
||||
if got := hub.EventKeyCount("im.message"); got != 1 {
|
||||
t.Errorf("hub.EventKeyCount(im.message) = %d, want 1", got)
|
||||
}
|
||||
if got := hub.SubCount("im.message"); got != 1 {
|
||||
t.Errorf("hub.SubCount(im.message) = %d, want 1 (legacy fallback to EventKey)", got)
|
||||
}
|
||||
if got := hub.SubCount("im.message:something"); got != 0 {
|
||||
t.Errorf("hub.SubCount(im.message:something) = %d, want 0 (should not exist)", got)
|
||||
}
|
||||
|
||||
if ackLine == "" {
|
||||
t.Fatal("HelloAck was empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHello_ModernClient_UsesSubscriptionID: a Hello with
|
||||
// non-empty subscription_id registers under that ID, not EventKey.
|
||||
func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
// Modern client: subscription_id explicitly set.
|
||||
subscriptionID := "mail.message:alice@example.com"
|
||||
hello := &protocol.Hello{
|
||||
PID: 8888,
|
||||
EventKey: "mail.message",
|
||||
EventTypes: []string{"mail.message.receive_v1"},
|
||||
SubscriptionID: subscriptionID, // modern: per-resource subscription
|
||||
}
|
||||
|
||||
br := bufio.NewReader(server)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.handleHello(server, br, hello)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Read the HelloAck from client side to let handleHello complete.
|
||||
clientReader := bufio.NewReader(client)
|
||||
ackLine, err := clientReader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read HelloAck: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handleHello did not return within 3s")
|
||||
}
|
||||
|
||||
// Assertions: registered under the subscription_id, not bare EventKey.
|
||||
if got := hub.ConnCount(); got != 1 {
|
||||
t.Errorf("hub.ConnCount = %d, want 1", got)
|
||||
}
|
||||
if got := hub.EventKeyCount("mail.message"); got != 1 {
|
||||
t.Errorf("hub.EventKeyCount(mail.message) = %d, want 1", got)
|
||||
}
|
||||
if got := hub.SubCount(subscriptionID); got != 1 {
|
||||
t.Errorf("hub.SubCount(%q) = %d, want 1 (modern: uses SubscriptionID)", subscriptionID, got)
|
||||
}
|
||||
if got := hub.SubCount("mail.message"); got != 0 {
|
||||
t.Errorf("hub.SubCount(mail.message) = %d, want 0 (modern: NOT registered under bare EventKey)", got)
|
||||
}
|
||||
|
||||
if ackLine == "" {
|
||||
t.Fatal("HelloAck was empty")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import (
|
||||
// Subscriber is the interface a connection must satisfy for Hub registration.
|
||||
type Subscriber interface {
|
||||
EventKey() string
|
||||
// SubscriptionID identifies the per-resource subscription for dedup purposes.
|
||||
// When no resource qualifier is needed it equals EventKey.
|
||||
SubscriptionID() string
|
||||
EventTypes() []string
|
||||
SendCh() chan interface{}
|
||||
PID() int
|
||||
@@ -34,8 +37,11 @@ type Subscriber interface {
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
subscribers map[Subscriber]struct{}
|
||||
keyCounts map[string]int
|
||||
// cleanupInProgress[key] holds a channel closed on release; presence means a cleanup lock is held.
|
||||
// subCounts is keyed by SubscriptionID (not EventKey) so that different
|
||||
// per-resource subscriptions sharing the same EventKey are deduped independently.
|
||||
subCounts map[string]int
|
||||
// cleanupInProgress[subscriptionID] holds a channel closed on release;
|
||||
// presence means a cleanup lock is held for that subscription.
|
||||
cleanupInProgress map[string]chan struct{}
|
||||
logger atomic.Pointer[log.Logger]
|
||||
}
|
||||
@@ -43,7 +49,7 @@ type Hub struct {
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
subscribers: make(map[Subscriber]struct{}),
|
||||
keyCounts: make(map[string]int),
|
||||
subCounts: make(map[string]int),
|
||||
cleanupInProgress: make(map[string]chan struct{}),
|
||||
}
|
||||
}
|
||||
@@ -51,7 +57,7 @@ func NewHub() *Hub {
|
||||
// SetLogger attaches a logger (nil tolerated).
|
||||
func (h *Hub) SetLogger(l *log.Logger) { h.logger.Store(l) }
|
||||
|
||||
// UnregisterAndIsLast removes s and reports whether it was last for its EventKey; stale unregisters are no-ops.
|
||||
// UnregisterAndIsLast removes s and reports whether it was last for its SubscriptionID; stale unregisters are no-ops.
|
||||
func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -59,34 +65,35 @@ func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
|
||||
return false
|
||||
}
|
||||
delete(h.subscribers, s)
|
||||
h.keyCounts[s.EventKey()]--
|
||||
isLast := h.keyCounts[s.EventKey()] == 0
|
||||
sid := s.SubscriptionID()
|
||||
h.subCounts[sid]--
|
||||
isLast := h.subCounts[sid] == 0
|
||||
if isLast {
|
||||
delete(h.keyCounts, s.EventKey())
|
||||
delete(h.subCounts, sid)
|
||||
}
|
||||
return isLast
|
||||
}
|
||||
|
||||
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for eventKey and no lock is held.
|
||||
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for subscriptionID and no lock is held.
|
||||
// Count==0 is rejected (would block future Register calls). On true return, caller MUST Release.
|
||||
func (h *Hub) AcquireCleanupLock(eventKey string) bool {
|
||||
func (h *Hub) AcquireCleanupLock(subscriptionID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.keyCounts[eventKey] != 1 {
|
||||
if h.subCounts[subscriptionID] != 1 {
|
||||
return false
|
||||
}
|
||||
if _, alreadyLocked := h.cleanupInProgress[eventKey]; alreadyLocked {
|
||||
if _, alreadyLocked := h.cleanupInProgress[subscriptionID]; alreadyLocked {
|
||||
return false
|
||||
}
|
||||
h.cleanupInProgress[eventKey] = make(chan struct{})
|
||||
h.cleanupInProgress[subscriptionID] = make(chan struct{})
|
||||
return true
|
||||
}
|
||||
|
||||
// ReleaseCleanupLock is idempotent; OnClose calls unconditionally.
|
||||
func (h *Hub) ReleaseCleanupLock(eventKey string) {
|
||||
func (h *Hub) ReleaseCleanupLock(subscriptionID string) {
|
||||
h.mu.Lock()
|
||||
ch := h.cleanupInProgress[eventKey]
|
||||
delete(h.cleanupInProgress, eventKey)
|
||||
ch := h.cleanupInProgress[subscriptionID]
|
||||
delete(h.cleanupInProgress, subscriptionID)
|
||||
h.mu.Unlock()
|
||||
if ch != nil {
|
||||
close(ch)
|
||||
@@ -94,23 +101,24 @@ func (h *Hub) ReleaseCleanupLock(eventKey string) {
|
||||
}
|
||||
|
||||
// RegisterAndIsFirst adds s to the hub and reports whether it's the first
|
||||
// subscriber for its EventKey. If a cleanup is in progress for
|
||||
// s.EventKey() (another conn holds the cleanup lock), this waits until
|
||||
// subscriber for its SubscriptionID. If a cleanup is in progress for
|
||||
// s.SubscriptionID() (another conn holds the cleanup lock), this waits until
|
||||
// cleanup releases before registering — closing the PreShutdownCheck ×
|
||||
// Hello TOCTOU race. The wait releases h.mu before blocking on the
|
||||
// channel, so concurrent operations on other keys aren't stalled.
|
||||
// channel, so concurrent operations on other subscriptions aren't stalled.
|
||||
func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
|
||||
sid := s.SubscriptionID()
|
||||
for {
|
||||
h.mu.Lock()
|
||||
ch, locked := h.cleanupInProgress[s.EventKey()]
|
||||
ch, locked := h.cleanupInProgress[sid]
|
||||
if locked {
|
||||
h.mu.Unlock()
|
||||
<-ch // wait for release, then re-check (defensive against races)
|
||||
continue
|
||||
}
|
||||
isFirst := h.keyCounts[s.EventKey()] == 0
|
||||
isFirst := h.subCounts[sid] == 0
|
||||
h.subscribers[s] = struct{}{}
|
||||
h.keyCounts[s.EventKey()]++
|
||||
h.subCounts[sid]++
|
||||
h.mu.Unlock()
|
||||
return isFirst
|
||||
}
|
||||
@@ -176,11 +184,25 @@ func (h *Hub) ConnCount() int {
|
||||
return len(h.subscribers)
|
||||
}
|
||||
|
||||
// EventKeyCount returns the number of subscribers registered for eventKey.
|
||||
// EventKeyCount returns total subscribers for the given EventKey, aggregating
|
||||
// across all SubscriptionIDs. For per-subscription counts use SubCount.
|
||||
func (h *Hub) EventKeyCount(eventKey string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.keyCounts[eventKey]
|
||||
count := 0
|
||||
for s := range h.subscribers {
|
||||
if s.EventKey() == eventKey {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// SubCount returns the count of subscribers for the given SubscriptionID.
|
||||
func (h *Hub) SubCount(subscriptionID string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.subCounts[subscriptionID]
|
||||
}
|
||||
|
||||
// BroadcastSourceStatus fans out a source-level status change to every
|
||||
@@ -205,10 +227,11 @@ func (h *Hub) Consumers() []protocol.ConsumerInfo {
|
||||
result := make([]protocol.ConsumerInfo, 0, len(h.subscribers))
|
||||
for s := range h.subscribers {
|
||||
result = append(result, protocol.ConsumerInfo{
|
||||
PID: s.PID(),
|
||||
EventKey: s.EventKey(),
|
||||
Received: s.Received(),
|
||||
Dropped: s.DroppedCount(),
|
||||
PID: s.PID(),
|
||||
EventKey: s.EventKey(),
|
||||
SubscriptionID: s.SubscriptionID(),
|
||||
Received: s.Received(),
|
||||
Dropped: s.DroppedCount(),
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestHubDroppedCountIncrements(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestPublishAssignsIncrementalSeq(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 10)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestPublishSourceTimeTakesPrecedence(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestPublishSourceTimeFallback(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ type alwaysFailSubscriber struct {
|
||||
}
|
||||
|
||||
func (s *alwaysFailSubscriber) EventKey() string { return s.eventKey }
|
||||
func (s *alwaysFailSubscriber) SubscriptionID() string { return s.eventKey }
|
||||
func (s *alwaysFailSubscriber) EventTypes() []string { return s.eventTypes }
|
||||
func (s *alwaysFailSubscriber) SendCh() chan interface{} { return s.sendCh }
|
||||
func (s *alwaysFailSubscriber) PID() int { return 0 }
|
||||
@@ -153,6 +154,7 @@ func newRaceSubscriber(key string, types []string, capacity int) *raceSubscriber
|
||||
}
|
||||
|
||||
func (s *raceSubscriber) EventKey() string { return s.eventKey }
|
||||
func (s *raceSubscriber) SubscriptionID() string { return s.eventKey }
|
||||
func (s *raceSubscriber) EventTypes() []string { return s.eventTypes }
|
||||
func (s *raceSubscriber) SendCh() chan interface{} { return s.sendCh }
|
||||
func (s *raceSubscriber) PID() int { return s.pid }
|
||||
|
||||
@@ -5,6 +5,7 @@ package bus
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -235,7 +236,10 @@ func newTestConn(eventKey string, eventTypes []string) *testConn {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *testConn) EventKey() string { return c.eventKey }
|
||||
func (c *testConn) EventKey() string { return c.eventKey }
|
||||
|
||||
// SubscriptionID falls back to EventKey for test mocks that don't set a separate subscription ID.
|
||||
func (c *testConn) SubscriptionID() string { return c.eventKey }
|
||||
func (c *testConn) EventTypes() []string { return c.eventTypes }
|
||||
func (c *testConn) SendCh() chan interface{} { return c.sendCh }
|
||||
func (c *testConn) PID() int { return c.pid }
|
||||
@@ -275,3 +279,79 @@ func (c *testConn) TrySend(msg interface{}) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SubscriptionID_Isolation(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
|
||||
|
||||
if !h.RegisterAndIsFirst(s1) {
|
||||
t.Error("s1 should be first for its subscription")
|
||||
}
|
||||
if !h.RegisterAndIsFirst(s2) {
|
||||
t.Error("s2 should ALSO be first (different SubscriptionID)")
|
||||
}
|
||||
if !h.UnregisterAndIsLast(s1) {
|
||||
t.Error("s1 should be last for mail.x:alice")
|
||||
}
|
||||
if !h.UnregisterAndIsLast(s2) {
|
||||
t.Error("s2 should be last for mail.x:bob")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SameSubscriptionID_NotFirst(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:alice")
|
||||
|
||||
if !h.RegisterAndIsFirst(s1) {
|
||||
t.Error("s1 first")
|
||||
}
|
||||
if h.RegisterAndIsFirst(s2) {
|
||||
t.Error("s2 same SubscriptionID should NOT be first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_EventKeyCount_AggregatesAcrossSubscriptions(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
|
||||
h.RegisterAndIsFirst(s1)
|
||||
h.RegisterAndIsFirst(s2)
|
||||
if got := h.EventKeyCount("mail.x"); got != 2 {
|
||||
t.Errorf("EventKeyCount(mail.x) = %d, want 2 (aggregated across subscriptions)", got)
|
||||
}
|
||||
if got := h.SubCount("mail.x:alice"); got != 1 {
|
||||
t.Errorf("SubCount(mail.x:alice) = %d, want 1", got)
|
||||
}
|
||||
if got := h.SubCount("mail.x:bob"); got != 1 {
|
||||
t.Errorf("SubCount(mail.x:bob) = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
h.RegisterAndIsFirst(s1)
|
||||
consumers := h.Consumers()
|
||||
if len(consumers) != 1 {
|
||||
t.Fatalf("got %d consumers, want 1", len(consumers))
|
||||
}
|
||||
if consumers[0].SubscriptionID != "mail.x:alice" {
|
||||
t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,22 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize params (resolve aliases like "me" -> real email) before fingerprint
|
||||
// compute, PreConsume, Match, Process. Must happen BEFORE doHello so the
|
||||
// SubscriptionID we send to bus reflects canonical values.
|
||||
if keyDef.NormalizeParams != nil {
|
||||
if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"normalize params for %s: %s", opts.EventKey, err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute subscription identity from normalized params + SubscriptionKey flags.
|
||||
subscriptionID := ComputeSubscriptionID(keyDef, opts.Params)
|
||||
|
||||
if opts.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
|
||||
@@ -81,13 +97,13 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType}, subscriptionID)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
var cleanup func()
|
||||
var cleanup func() error
|
||||
if ack.FirstForKey && keyDef.PreConsume != nil {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] running pre-consume setup...\n")
|
||||
@@ -113,14 +129,22 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
if cleanup != nil {
|
||||
switch {
|
||||
case r != nil:
|
||||
fmt.Fprintf(errOut, "WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n", opts.EventKey)
|
||||
cleanup()
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n",
|
||||
opts.EventKey)
|
||||
if cleanupErr := cleanup(); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: cleanup also failed during panic recovery: %v\n", cleanupErr)
|
||||
}
|
||||
case lastForKey:
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] running cleanup...\n")
|
||||
}
|
||||
cleanup()
|
||||
if !opts.Quiet {
|
||||
if cleanupErr := cleanup(); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: cleanup failed: %v (server-side subscribe is idempotent — residual record will be overwritten on next subscribe)\n",
|
||||
cleanupErr)
|
||||
} else if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] cleanup done.\n")
|
||||
}
|
||||
}
|
||||
@@ -144,7 +168,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
writeReadyMarker(errOut, opts)
|
||||
|
||||
return consumeLoop(ctx, conn, br, keyDef, opts, &lastForKey, &emitted)
|
||||
return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted)
|
||||
}
|
||||
|
||||
func truncateDuration(d time.Duration) time.Duration {
|
||||
|
||||
101
internal/event/consume/consume_test.go
Normal file
101
internal/event/consume/consume_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// fakeRT is a minimal event.APIClient mock.
|
||||
type fakeRT struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRT) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||
return nil, f.err
|
||||
}
|
||||
|
||||
func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) {
|
||||
// Drives the real Run() path: NormalizeParams fails before EnsureBus, so no
|
||||
// bus is contacted, yet the production error-wrapping is exercised — if Run()
|
||||
// ever stops wrapping, this test fails.
|
||||
const key = "test.evt_normalize_fail"
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}},
|
||||
NormalizeParams: func(_ context.Context, _ event.APIClient, _ map[string]string) error {
|
||||
return errors.New("simulated normalize failure")
|
||||
},
|
||||
})
|
||||
defer event.UnregisterKeyForTest(key)
|
||||
|
||||
err := Run(context.Background(), transport.New(), "app", "", "", Options{
|
||||
EventKey: key,
|
||||
Runtime: &fakeRT{},
|
||||
Quiet: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected Run to fail when NormalizeParams errors")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "normalize params for "+key+":") {
|
||||
t.Errorf("error not wrapped with EventKey prefix: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "simulated normalize failure") {
|
||||
t.Errorf("underlying error not propagated: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoHello_PassesSubscriptionIDToWire(t *testing.T) {
|
||||
a, b := net.Pipe()
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
|
||||
// Server-side: read Hello, decode, assert SubscriptionID, send ack
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
br := bufio.NewReader(b)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
done <- "READ_ERR:" + err.Error()
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
done <- "DECODE_ERR:" + err.Error()
|
||||
return
|
||||
}
|
||||
if hello, ok := msg.(*protocol.Hello); ok {
|
||||
done <- hello.SubscriptionID
|
||||
// send ack so client can return
|
||||
ack := protocol.NewHelloAck("v1", true)
|
||||
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
|
||||
} else {
|
||||
done <- "WRONG_TYPE"
|
||||
}
|
||||
}()
|
||||
|
||||
ack, _, err := doHello(a, "mail.x", []string{"mail.x"}, "mail.x:alice")
|
||||
if err != nil {
|
||||
t.Fatalf("doHello error: %v", err)
|
||||
}
|
||||
if ack == nil {
|
||||
t.Fatal("got nil ack")
|
||||
}
|
||||
got := <-done
|
||||
if got != "mail.x:alice" {
|
||||
t.Errorf("Hello.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
41
internal/event/consume/fingerprint.go
Normal file
41
internal/event/consume/fingerprint.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// ComputeSubscriptionID returns a stable identifier scoped to (EventKey, values
|
||||
// of the ParamDefs marked SubscriptionKey); the framework uses it to dedup
|
||||
// PreConsume/cleanup gates and key Hub counts per-subscription. No SubscriptionKey
|
||||
// params -> returns def.Key verbatim (legacy one-dimensional behavior).
|
||||
//
|
||||
// Stability contract: same EventKey + same normalized param values -> same ID
|
||||
// across CLI versions; changing the encoding requires a wire-format bump.
|
||||
func ComputeSubscriptionID(def *event.KeyDefinition, params map[string]string) string {
|
||||
type kv struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
var subParams []kv
|
||||
for _, p := range def.Params {
|
||||
if !p.SubscriptionKey {
|
||||
continue
|
||||
}
|
||||
subParams = append(subParams, kv{Name: p.Name, Value: params[p.Name]})
|
||||
}
|
||||
if len(subParams) == 0 {
|
||||
return def.Key
|
||||
}
|
||||
sort.Slice(subParams, func(i, j int) bool { return subParams[i].Name < subParams[j].Name })
|
||||
raw, _ := json.Marshal(subParams) // err impossible: kv has no unmarshalable fields
|
||||
sum := sha256.Sum256(raw)
|
||||
return def.Key + ":" + base64.RawURLEncoding.EncodeToString(sum[:12])
|
||||
}
|
||||
126
internal/event/consume/fingerprint_test.go
Normal file
126
internal/event/consume/fingerprint_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestComputeSubscriptionID(t *testing.T) {
|
||||
makeDef := func(subKeyNames ...string) *event.KeyDefinition {
|
||||
def := &event.KeyDefinition{Key: "test.evt"}
|
||||
marked := make(map[string]bool, len(subKeyNames))
|
||||
for _, n := range subKeyNames {
|
||||
marked[n] = true
|
||||
}
|
||||
for _, n := range []string{"alpha", "beta", "gamma"} {
|
||||
def.Params = append(def.Params, event.ParamDef{Name: n, SubscriptionKey: marked[n]})
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
t.Run("no SubscriptionKey params returns EventKey verbatim", func(t *testing.T) {
|
||||
def := makeDef()
|
||||
got := ComputeSubscriptionID(def, map[string]string{"alpha": "x", "beta": "y"})
|
||||
if got != "test.evt" {
|
||||
t.Errorf("got %q, want %q", got, "test.evt")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single SubscriptionKey param: non-sub params do not leak into ID", func(t *testing.T) {
|
||||
def := makeDef("alpha")
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "ignored"})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "different"})
|
||||
if id1 != id2 {
|
||||
t.Errorf("non-SubscriptionKey param change leaked into ID: %q vs %q", id1, id2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different SubscriptionKey value produces different ID", func(t *testing.T) {
|
||||
def := makeDef("alpha")
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "v1"})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "v2"})
|
||||
if id1 == id2 {
|
||||
t.Errorf("different values produced same ID: %q", id1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_Stability(t *testing.T) {
|
||||
// Param order in the ParamDef list must not affect the result (sorted by name internally).
|
||||
def1 := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{
|
||||
{Name: "b", SubscriptionKey: true},
|
||||
{Name: "a", SubscriptionKey: true},
|
||||
},
|
||||
}
|
||||
def2 := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{
|
||||
{Name: "a", SubscriptionKey: true},
|
||||
{Name: "b", SubscriptionKey: true},
|
||||
},
|
||||
}
|
||||
id1 := ComputeSubscriptionID(def1, map[string]string{"a": "1", "b": "2"})
|
||||
id2 := ComputeSubscriptionID(def2, map[string]string{"a": "1", "b": "2"})
|
||||
if id1 != id2 {
|
||||
t.Errorf("order-sensitive: id1=%q id2=%q", id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_Format(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "mail.user_mailbox.event.message_received_v1",
|
||||
Params: []event.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||
}
|
||||
id := ComputeSubscriptionID(def, map[string]string{"mailbox": "liuxinyang@example.com"})
|
||||
prefix := "mail.user_mailbox.event.message_received_v1:"
|
||||
if !strings.HasPrefix(id, prefix) {
|
||||
t.Fatalf("missing prefix: %q", id)
|
||||
}
|
||||
suffix := strings.TrimPrefix(id, prefix)
|
||||
if len(suffix) != 16 {
|
||||
t.Errorf("fingerprint length = %d, want 16", len(suffix))
|
||||
}
|
||||
for _, c := range suffix {
|
||||
isValid := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
|
||||
if !isValid {
|
||||
t.Errorf("non-base64URL char in fingerprint: %q", suffix)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_UnicodeAndSpecialChars(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{{Name: "value", SubscriptionKey: true}},
|
||||
}
|
||||
for _, val := range []string{"中文", "emoji🚀", "with spaces", "with:colons", "with\"quotes"} {
|
||||
id := ComputeSubscriptionID(def, map[string]string{"value": val})
|
||||
if !strings.HasPrefix(id, "test.evt:") || len(id) != len("test.evt:")+16 {
|
||||
t.Errorf("ID malformed for value=%q: %q (len=%d)", val, id, len(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_EmptyValue(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{{Name: "x", SubscriptionKey: true}},
|
||||
}
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"x": ""})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{}) // missing entirely
|
||||
if id1 != id2 {
|
||||
t.Errorf("empty value should be indistinguishable from missing: %q vs %q", id1, id2)
|
||||
}
|
||||
id3 := ComputeSubscriptionID(def, map[string]string{"x": "nonempty"})
|
||||
if id1 == id3 {
|
||||
t.Errorf("empty and nonempty produced same ID: %q", id1)
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@ const helloAckTimeout = 5 * time.Second // symmetric with bus-side hello read de
|
||||
|
||||
// doHello returns a bufio.Reader holding any bytes already pulled off conn so events
|
||||
// buffered with the ack in one TCP segment aren't dropped.
|
||||
func doHello(conn net.Conn, eventKey string, eventTypes []string) (*protocol.HelloAck, *bufio.Reader, error) {
|
||||
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1")
|
||||
func doHello(conn net.Conn, eventKey string, eventTypes []string, subscriptionID string) (*protocol.HelloAck, *bufio.Reader, error) {
|
||||
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1", subscriptionID)
|
||||
if err := protocol.EncodeWithDeadline(conn, hello, protocol.WriteTimeout); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestDoHello_ReadDeadline(t *testing.T) {
|
||||
start := time.Now()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := doHello(client, "im.msg", []string{"im.msg"})
|
||||
_, _, err := doHello(client, "im.msg", []string{"im.msg"}, "")
|
||||
done <- err
|
||||
}()
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
// consumeLoop reads events and dispatches to workers; cancels on terminal sink errors.
|
||||
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, lastForKey *bool, emitted *atomic.Int64) error {
|
||||
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, subscriptionID string, lastForKey *bool, emitted *atomic.Int64) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
@@ -185,7 +185,7 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
|
||||
close(stopReader)
|
||||
<-readerDone
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
*lastForKey = checkLastForKey(conn, opts.EventKey)
|
||||
*lastForKey = checkLastForKey(conn, opts.EventKey, subscriptionID)
|
||||
conn.Close()
|
||||
case <-allDone:
|
||||
// bus-side close; can't query, assume last
|
||||
@@ -199,13 +199,19 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
|
||||
|
||||
// processAndOutput returns (wrote, err); err non-nil only for sink.Write failures.
|
||||
func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: evt.EventType,
|
||||
Payload: evt.Payload,
|
||||
}
|
||||
|
||||
// Synchronous Match filter runs before any work (Process / sink write).
|
||||
if keyDef.Match != nil && !keyDef.Match(raw, opts.Params) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var result json.RawMessage
|
||||
|
||||
if keyDef.Process != nil {
|
||||
raw := &event.RawEvent{
|
||||
EventType: evt.EventType,
|
||||
Payload: evt.Payload,
|
||||
}
|
||||
var err error
|
||||
result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params)
|
||||
if err != nil {
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
|
||||
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
|
||||
if err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 2 {
|
||||
@@ -169,7 +169,7 @@ func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 1 {
|
||||
@@ -196,12 +196,96 @@ func TestConsumeLoop_CompileJQFailsEarly(t *testing.T) {
|
||||
|
||||
var lastForKey bool
|
||||
var emitted atomic.Int64
|
||||
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
|
||||
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
|
||||
if err == nil {
|
||||
t.Fatal("consumeLoop should fail immediately on bad jq expression")
|
||||
}
|
||||
}
|
||||
|
||||
// captureSink is a minimal Sink for unit-testing processAndOutput directly.
|
||||
type captureSink struct {
|
||||
written []json.RawMessage
|
||||
}
|
||||
|
||||
func (s *captureSink) Write(data json.RawMessage) error {
|
||||
s.written = append(s.written, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_DropsEvent(t *testing.T) {
|
||||
calledProcess := false
|
||||
keyDef := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Match: func(raw *event.RawEvent, params map[string]string) bool {
|
||||
return false
|
||||
},
|
||||
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
|
||||
calledProcess = true
|
||||
return json.RawMessage(`{}`), nil
|
||||
},
|
||||
}
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wrote {
|
||||
t.Error("Match returned false but event was written")
|
||||
}
|
||||
if calledProcess {
|
||||
t.Error("Process was called even though Match returned false")
|
||||
}
|
||||
if len(sink.written) != 0 {
|
||||
t.Errorf("sink received %d events, want 0", len(sink.written))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_NilAcceptsAll(t *testing.T) {
|
||||
keyDef := &event.KeyDefinition{Key: "test.evt"} // no Match, no Process
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil || !wrote {
|
||||
t.Errorf("expected wrote=true err=nil; got wrote=%v err=%v", wrote, err)
|
||||
}
|
||||
if len(sink.written) != 1 {
|
||||
t.Errorf("sink received %d events, want 1", len(sink.written))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_RunsBeforeProcess(t *testing.T) {
|
||||
// Record the actual call sequence — a bare call-count check would still
|
||||
// pass if Process ran before Match.
|
||||
var order []string
|
||||
keyDef := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Match: func(raw *event.RawEvent, params map[string]string) bool {
|
||||
order = append(order, "match")
|
||||
return true
|
||||
},
|
||||
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
|
||||
order = append(order, "process")
|
||||
return raw.Payload, nil
|
||||
},
|
||||
}
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !wrote {
|
||||
t.Error("expected wrote=true")
|
||||
}
|
||||
if len(order) != 2 || order[0] != "match" || order[1] != "process" {
|
||||
t.Errorf("call order = %v, want [match process]", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTerminalSinkError(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
|
||||
@@ -16,8 +16,8 @@ const preShutdownAckTimeout = 2 * time.Second
|
||||
|
||||
// checkLastForKey atomically reserves a cleanup lock; on any error defaults to true
|
||||
// (cleanup-on-error is safer than leaking server state). Discards non-ack frames in flight.
|
||||
func checkLastForKey(conn net.Conn, eventKey string) bool {
|
||||
msg := protocol.NewPreShutdownCheck(eventKey)
|
||||
func checkLastForKey(conn net.Conn, eventKey string, subscriptionID string) bool {
|
||||
msg := protocol.NewPreShutdownCheck(eventKey, subscriptionID)
|
||||
if err := protocol.EncodeWithDeadline(conn, msg, protocol.WriteTimeout); err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
@@ -38,7 +40,7 @@ func TestCheckLastForKey_IgnoresNonAckFrames(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
if got != false {
|
||||
t.Errorf("checkLastForKey = %v, want false", got)
|
||||
}
|
||||
@@ -62,7 +64,7 @@ func TestCheckLastForKey_ReturnsAckValue(t *testing.T) {
|
||||
_ = protocol.Encode(server, ack)
|
||||
}()
|
||||
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
if got != true {
|
||||
t.Errorf("checkLastForKey = %v, want true", got)
|
||||
}
|
||||
@@ -83,7 +85,7 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if got != true {
|
||||
@@ -93,3 +95,39 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
|
||||
t.Errorf("elapsed = %v, expected ~%v (timeout-bounded)", elapsed, preShutdownAckTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLastForKey_SendsSubscriptionID(t *testing.T) {
|
||||
a, b := net.Pipe()
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
br := bufio.NewReader(b)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
done <- "READ_ERR"
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
done <- "DECODE_ERR"
|
||||
return
|
||||
}
|
||||
check, ok := msg.(*protocol.PreShutdownCheck)
|
||||
if !ok {
|
||||
done <- "WRONG_TYPE"
|
||||
return
|
||||
}
|
||||
done <- check.SubscriptionID
|
||||
// Reply with ack so client returns
|
||||
ack := protocol.NewPreShutdownAck(true)
|
||||
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
|
||||
}()
|
||||
|
||||
_ = checkLastForKey(a, "mail.x", "mail.x:alice")
|
||||
got := <-done
|
||||
if got != "mail.x:alice" {
|
||||
t.Errorf("PreShutdownCheck.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,3 +77,88 @@ func TestDecodeUnknownType(t *testing.T) {
|
||||
t.Error("expected error for unknown type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeHello_WithSubscriptionID(t *testing.T) {
|
||||
msg := &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: 12345,
|
||||
EventKey: "mail.user_mailbox.event.message_received_v1",
|
||||
EventTypes: []string{"mail.user_mailbox.event.message_received_v1"},
|
||||
Version: "v1",
|
||||
SubscriptionID: "mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs",
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := buf.Bytes()
|
||||
if !bytes.Contains(line, []byte(`"subscription_id":"mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs"`)) {
|
||||
t.Errorf("subscription_id not serialized: %s", string(line))
|
||||
}
|
||||
decoded, err := Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hello, ok := decoded.(*Hello)
|
||||
if !ok {
|
||||
t.Fatalf("expected *Hello, got %T", decoded)
|
||||
}
|
||||
if hello.SubscriptionID != msg.SubscriptionID {
|
||||
t.Errorf("roundtrip subscription_id: got %q want %q", hello.SubscriptionID, msg.SubscriptionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeHello_EmptySubscriptionIDOmitted(t *testing.T) {
|
||||
msg := &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: 1,
|
||||
EventKey: "k",
|
||||
EventTypes: []string{"k"},
|
||||
Version: "v1",
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(buf.Bytes(), []byte("subscription_id")) {
|
||||
t.Errorf("empty subscription_id should be omitted: %s", buf.String())
|
||||
}
|
||||
decoded, _ := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
|
||||
hello := decoded.(*Hello)
|
||||
if hello.SubscriptionID != "" {
|
||||
t.Errorf("got %q, want empty", hello.SubscriptionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodePreShutdownCheck_WithSubscriptionID(t *testing.T) {
|
||||
msg := &PreShutdownCheck{
|
||||
Type: MsgTypePreShutdownCheck,
|
||||
EventKey: "mail.x",
|
||||
SubscriptionID: "mail.x:abc",
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := decoded.(*PreShutdownCheck)
|
||||
if got.SubscriptionID != msg.SubscriptionID {
|
||||
t.Errorf("roundtrip: got %q want %q", got.SubscriptionID, msg.SubscriptionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusResponse_ConsumerInfo_SubscriptionID(t *testing.T) {
|
||||
msg := NewStatusResponse(7, 120, 1, []ConsumerInfo{
|
||||
{PID: 99, EventKey: "mail.x", SubscriptionID: "mail.x:abc", Received: 5, Dropped: 0},
|
||||
})
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte(`"subscription_id":"mail.x:abc"`)) {
|
||||
t.Errorf("ConsumerInfo.SubscriptionID missing from JSON: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +34,12 @@ type SourceStatus struct {
|
||||
}
|
||||
|
||||
type Hello struct {
|
||||
Type string `json:"type"`
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
EventTypes []string `json:"event_types"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
EventTypes []string `json:"event_types"`
|
||||
Version string `json:"version"`
|
||||
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey on bus side
|
||||
}
|
||||
|
||||
type HelloAck struct {
|
||||
@@ -61,10 +62,11 @@ type Bye struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// PreShutdownCheck atomically reserves the cleanup lock for EventKey.
|
||||
// PreShutdownCheck atomically reserves the cleanup lock for (EventKey, SubscriptionID).
|
||||
type PreShutdownCheck struct {
|
||||
Type string `json:"type"`
|
||||
EventKey string `json:"event_key"`
|
||||
Type string `json:"type"`
|
||||
EventKey string `json:"event_key"`
|
||||
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey
|
||||
}
|
||||
|
||||
type PreShutdownAck struct {
|
||||
@@ -77,10 +79,11 @@ type StatusQuery struct {
|
||||
}
|
||||
|
||||
type ConsumerInfo struct {
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
Received int64 `json:"received"`
|
||||
Dropped int64 `json:"dropped"`
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
SubscriptionID string `json:"subscription_id,omitempty"`
|
||||
Received int64 `json:"received"`
|
||||
Dropped int64 `json:"dropped"`
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
@@ -95,13 +98,14 @@ type Shutdown struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func NewHello(pid int, eventKey string, eventTypes []string, version string) *Hello {
|
||||
func NewHello(pid int, eventKey string, eventTypes []string, version string, subscriptionID string) *Hello {
|
||||
return &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: pid,
|
||||
EventKey: eventKey,
|
||||
EventTypes: eventTypes,
|
||||
Version: version,
|
||||
Type: MsgTypeHello,
|
||||
PID: pid,
|
||||
EventKey: eventKey,
|
||||
EventTypes: eventTypes,
|
||||
Version: version,
|
||||
SubscriptionID: subscriptionID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +128,8 @@ func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.Ra
|
||||
}
|
||||
}
|
||||
|
||||
func NewPreShutdownCheck(eventKey string) *PreShutdownCheck {
|
||||
return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey}
|
||||
func NewPreShutdownCheck(eventKey, subscriptionID string) *PreShutdownCheck {
|
||||
return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey, SubscriptionID: subscriptionID}
|
||||
}
|
||||
|
||||
func NewPreShutdownAck(lastForKey bool) *PreShutdownAck {
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
// Every NewXxx helper must set the Type discriminator (Decode rejects messages without it).
|
||||
func TestConstructors_PinTypeField(t *testing.T) {
|
||||
if got := NewHello(1, "k", []string{"t"}, "v1"); got.Type != MsgTypeHello {
|
||||
if got := NewHello(1, "k", []string{"t"}, "v1", ""); got.Type != MsgTypeHello {
|
||||
t.Errorf("NewHello.Type = %q, want %q", got.Type, MsgTypeHello)
|
||||
}
|
||||
if got := NewHelloAck("v1", true); got.Type != MsgTypeHelloAck || !got.FirstForKey {
|
||||
@@ -26,7 +26,7 @@ func TestConstructors_PinTypeField(t *testing.T) {
|
||||
if got := NewEvent("im.msg", "e1", "", 7, json.RawMessage(`{}`)); got.Type != MsgTypeEvent || got.Seq != 7 {
|
||||
t.Errorf("NewEvent mismatch: %+v", got)
|
||||
}
|
||||
if got := NewPreShutdownCheck("k"); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" {
|
||||
if got := NewPreShutdownCheck("k", ""); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" {
|
||||
t.Errorf("NewPreShutdownCheck mismatch: %+v", got)
|
||||
}
|
||||
if got := NewPreShutdownAck(true); got.Type != MsgTypePreShutdownAck || !got.LastForKey {
|
||||
@@ -63,7 +63,7 @@ func TestEncode_DecodeRoundtripAllTypes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
roundtrip(t, NewHelloAck("v1", true), &HelloAck{})
|
||||
roundtrip(t, NewPreShutdownCheck("im.msg"), &PreShutdownCheck{})
|
||||
roundtrip(t, NewPreShutdownCheck("im.msg", ""), &PreShutdownCheck{})
|
||||
roundtrip(t, NewPreShutdownAck(false), &PreShutdownAck{})
|
||||
roundtrip(t, NewStatusQuery(), &StatusQuery{})
|
||||
roundtrip(t, NewStatusResponse(7, 120, 1, []ConsumerInfo{{PID: 99, EventKey: "k"}}), &StatusResponse{})
|
||||
|
||||
@@ -55,6 +55,23 @@ type ParamDef struct {
|
||||
Default string `json:"default,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Values []ParamValue `json:"values,omitempty"`
|
||||
|
||||
// SubscriptionKey marks this param as part of the subscription identity.
|
||||
// Two consumers of the same EventKey but different values for any
|
||||
// SubscriptionKey-marked param are treated as DISTINCT subscriptions:
|
||||
// PreConsume runs once per (EventKey, SubscriptionID), cleanup runs once per
|
||||
// (EventKey, SubscriptionID).
|
||||
//
|
||||
// CONTRACT: only mark a param SubscriptionKey if the EventKey's server-side
|
||||
// subscribe/unsubscribe API is itself scoped to that resource. Lark keys the
|
||||
// subscription record by (app, user, event_type) and overwrites it rather
|
||||
// than reference-counting, so for a non-per-resource API the cleanup of one
|
||||
// resource's last consumer unsubscribes the shared record and silently cuts
|
||||
// off every other resource sharing that event_type.
|
||||
//
|
||||
// Default false = the param is a filter / formatting / metadata param
|
||||
// and does not affect subscription identity.
|
||||
SubscriptionKey bool `json:"subscription_key,omitempty"`
|
||||
}
|
||||
|
||||
type ProcessFunc = func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error)
|
||||
@@ -83,10 +100,44 @@ type KeyDefinition struct {
|
||||
|
||||
Schema SchemaDef `json:"schema"`
|
||||
|
||||
// NormalizeParams canonicalizes param values BEFORE fingerprint compute,
|
||||
// PreConsume, Match, and Process. Mutates the params map in place.
|
||||
// May call OAPI; runs once per consumer at startup.
|
||||
//
|
||||
// Use cases: resolve aliases ("me" -> real email, a name -> an ID),
|
||||
// trim whitespace. On error, consume fails (no retry); caller gets the
|
||||
// wrapped error.
|
||||
//
|
||||
// Default nil = no normalization, params pass through unchanged.
|
||||
NormalizeParams func(ctx context.Context, rt APIClient, params map[string]string) error `json:"-"`
|
||||
|
||||
// Process required when Schema.Custom is Processed output; must be nil when Native is used.
|
||||
//
|
||||
// Convention: returning (nil, nil) signals "drop this event" — the
|
||||
// consumer loop will skip writing it to sink and not advance the
|
||||
// emitted counter. Useful for async filtering (e.g. fetch metadata,
|
||||
// drop if folder doesn't match). For sync filters that don't need
|
||||
// OAPI, use Match instead.
|
||||
Process func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) `json:"-"`
|
||||
|
||||
PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func(), err error) `json:"-"`
|
||||
// Match is a synchronous payload filter run on every received event
|
||||
// BEFORE Process. Return false to drop the event without further work.
|
||||
//
|
||||
// Signature deliberately omits ctx/rt to physically enforce "no OAPI
|
||||
// calls in Match". For filters that need a metadata fetch first, use
|
||||
// Process and return nil to drop.
|
||||
//
|
||||
// Default nil = accept all events.
|
||||
Match func(raw *RawEvent, params map[string]string) bool `json:"-"`
|
||||
|
||||
// PreConsume runs once per (EventKey, SubscriptionID) when this consumer
|
||||
// is first for that scope. Returns a cleanup function that the framework
|
||||
// invokes when this consumer is the last for its scope.
|
||||
//
|
||||
// The cleanup's error return is honored: on nil the framework prints
|
||||
// "[event] cleanup done."; on non-nil it prints a WARN with an
|
||||
// idempotency note.
|
||||
PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func() error, err error) `json:"-"`
|
||||
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// spinnerFrames are braille spinner glyphs cycled to animate progress.
|
||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
const (
|
||||
spinnerInterval = 80 * time.Millisecond
|
||||
spinnerHideCursor = "\x1b[?25l"
|
||||
spinnerShowCursor = "\x1b[?25h"
|
||||
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
|
||||
)
|
||||
|
||||
// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
|
||||
// until the returned stop() is called, e.g.:
|
||||
//
|
||||
// ⠹ Publishing dev → main... 3s
|
||||
//
|
||||
// It is meant for slow operations (long polls, first-time provisioning) so the
|
||||
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
|
||||
// animation never pollutes stdout — the JSON/pretty result stays clean.
|
||||
//
|
||||
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
|
||||
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
|
||||
// the stderr-TTY check (IOStreams.ErrIsTerminal), not the output format: the
|
||||
// spinner is stderr-only and self-clears, so it is shown in JSON mode too.
|
||||
//
|
||||
// stop() clears the spinner line, restores the cursor, and blocks until the
|
||||
// render goroutine has finished — so callers can safely write the result to
|
||||
// stdout/stderr immediately after. Call stop() BEFORE printing the result, and
|
||||
// it is safe to call more than once (e.g. an explicit call plus a defer).
|
||||
func StartSpinner(w io.Writer, enabled bool, label string) func() {
|
||||
if !enabled || w == nil {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
start := time.Now()
|
||||
|
||||
go func() {
|
||||
defer close(finished)
|
||||
frame := 0
|
||||
fmt.Fprint(w, spinnerHideCursor)
|
||||
render := func() {
|
||||
elapsed := int(time.Since(start).Seconds())
|
||||
fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed)
|
||||
frame = (frame + 1) % len(spinnerFrames)
|
||||
}
|
||||
render()
|
||||
ticker := time.NewTicker(spinnerInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
fmt.Fprint(w, spinnerClearLine+spinnerShowCursor)
|
||||
return
|
||||
case <-ticker.C:
|
||||
render()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
close(done)
|
||||
<-finished // wait for the line to be cleared before returning
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent.
|
||||
func TestStartSpinner_DisabledIsNoop(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
stop := StartSpinner(&buf, false, "working")
|
||||
stop()
|
||||
stop() // idempotent
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("disabled spinner wrote %q, want nothing", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic.
|
||||
func TestStartSpinner_NilWriterIsNoop(t *testing.T) {
|
||||
stop := StartSpinner(nil, true, "working")
|
||||
stop() // must not panic
|
||||
}
|
||||
|
||||
// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop.
|
||||
func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
stop := StartSpinner(&buf, true, "Publishing")
|
||||
// The goroutine renders the first frame synchronously before selecting on
|
||||
// the stop channel, so even an immediate stop() yields one full cycle.
|
||||
stop()
|
||||
stop() // idempotent, must not panic or double-write after finished
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, spinnerHideCursor) {
|
||||
t.Errorf("missing hide-cursor escape:\n%q", out)
|
||||
}
|
||||
if !strings.Contains(out, spinnerFrames[0]) {
|
||||
t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out)
|
||||
}
|
||||
if !strings.Contains(out, "Publishing...") {
|
||||
t.Errorf("missing label:\n%q", out)
|
||||
}
|
||||
if !strings.Contains(out, spinnerClearLine) {
|
||||
t.Errorf("missing clear-line escape:\n%q", out)
|
||||
}
|
||||
if !strings.HasSuffix(out, spinnerShowCursor) {
|
||||
t.Errorf("must end by restoring the cursor:\n%q", out)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ var migratedCommonHelperPaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/apps/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
|
||||
@@ -19,6 +19,7 @@ var migratedEnvelopePaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/apps/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
@@ -15,7 +15,8 @@
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
"arm64",
|
||||
"riscv64"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
|
||||
@@ -33,6 +33,7 @@ build_target darwin arm64
|
||||
build_target linux amd64
|
||||
build_target darwin amd64
|
||||
build_target linux arm64
|
||||
build_target linux riscv64
|
||||
build_target windows amd64
|
||||
build_target windows arm64
|
||||
|
||||
@@ -55,6 +56,7 @@ const platformMap = {
|
||||
const archMap = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
riscv64: "riscv64",
|
||||
};
|
||||
|
||||
const platform = platformMap[process.platform];
|
||||
|
||||
@@ -30,6 +30,7 @@ const PLATFORM_MAP = {
|
||||
const ARCH_MAP = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
riscv64: "riscv64",
|
||||
};
|
||||
|
||||
const platform = PLATFORM_MAP[process.platform];
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -32,7 +31,7 @@ var AppsAccessScopeGet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -45,7 +44,7 @@ var AppsAccessScopeSet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return validateAccessScopeFlags(rctx)
|
||||
},
|
||||
@@ -90,36 +89,42 @@ func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
|
||||
switch scope {
|
||||
case "specific":
|
||||
if targets == "" {
|
||||
return output.ErrValidation("--targets is required when --scope=specific")
|
||||
return appsValidationParamError("--targets", "--targets is required when --scope=specific")
|
||||
}
|
||||
if err := validateTargetsJSON(targets); err != nil {
|
||||
return err
|
||||
}
|
||||
if approver != "" && !applyEnabled {
|
||||
return output.ErrValidation("--approver requires --apply-enabled")
|
||||
return appsValidationParamError("--approver", "--approver requires --apply-enabled")
|
||||
}
|
||||
if requireLogin {
|
||||
return output.ErrValidation("--require-login is not allowed when --scope=specific")
|
||||
return appsValidationParamError("--require-login", "--require-login is not allowed when --scope=specific")
|
||||
}
|
||||
case "public":
|
||||
if targets != "" {
|
||||
return output.ErrValidation("--targets is not allowed when --scope=public")
|
||||
return appsValidationParamError("--targets", "--targets is not allowed when --scope=public")
|
||||
}
|
||||
if applyEnabled {
|
||||
return output.ErrValidation("--apply-enabled is not allowed when --scope=public")
|
||||
return appsValidationParamError("--apply-enabled", "--apply-enabled is not allowed when --scope=public")
|
||||
}
|
||||
if approver != "" {
|
||||
return output.ErrValidation("--approver is not allowed when --scope=public")
|
||||
return appsValidationParamError("--approver", "--approver is not allowed when --scope=public")
|
||||
}
|
||||
if !rctx.Cmd.Flags().Changed("require-login") {
|
||||
return output.ErrValidation("--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
|
||||
return appsValidationParamError("--require-login", "--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
|
||||
}
|
||||
case "tenant":
|
||||
if targets != "" || applyEnabled || approver != "" || requireLogin {
|
||||
return output.ErrValidation("no extra flags allowed when --scope=tenant")
|
||||
return appsValidationError("no extra flags allowed when --scope=tenant").
|
||||
WithParams(
|
||||
appsInvalidParam("--targets", "not allowed when --scope=tenant"),
|
||||
appsInvalidParam("--apply-enabled", "not allowed when --scope=tenant"),
|
||||
appsInvalidParam("--approver", "not allowed when --scope=tenant"),
|
||||
appsInvalidParam("--require-login", "not allowed when --scope=tenant"),
|
||||
)
|
||||
}
|
||||
default:
|
||||
return output.ErrValidation("--scope must be specific / public / tenant")
|
||||
return appsValidationParamError("--scope", "--scope must be specific / public / tenant")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -127,18 +132,18 @@ func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
|
||||
func validateTargetsJSON(targetsJSON string) error {
|
||||
var items []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(targetsJSON), &items); err != nil {
|
||||
return output.ErrValidation("--targets is not valid JSON: %v", err)
|
||||
return appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return output.ErrValidation("--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
|
||||
return appsValidationParamError("--targets", "--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
|
||||
}
|
||||
for i, t := range items {
|
||||
typ, _ := t["type"].(string)
|
||||
if !allowedAccessTargetTypes[typ] {
|
||||
return output.ErrValidation("--targets[%d].type %q must be one of: user / department / chat", i, typ)
|
||||
return appsValidationParamError("--targets", "--targets[%d].type %q must be one of: user / department / chat", i, typ)
|
||||
}
|
||||
if id, _ := t["id"].(string); strings.TrimSpace(id) == "" {
|
||||
return output.ErrValidation("--targets[%d].id is empty", i)
|
||||
return appsValidationParamError("--targets", "--targets[%d].id is empty", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -157,7 +162,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{},
|
||||
scope := rctx.Str("scope")
|
||||
enum, ok := scopeStringToServerEnum[scope]
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("--scope must be specific / public / tenant, got %q", scope)
|
||||
return nil, appsValidationParamError("--scope", "--scope must be specific / public / tenant, got %q", scope)
|
||||
}
|
||||
body := map[string]interface{}{"scope": enum}
|
||||
|
||||
@@ -166,7 +171,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{},
|
||||
// 用户传统一格式 [{type:user|department|chat, id:...}],body 里拆 3 个并列数组发后端。
|
||||
var targets []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(rctx.Str("targets")), &targets); err != nil {
|
||||
return nil, output.ErrValidation("--targets is not valid JSON: %v", err)
|
||||
return nil, appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err)
|
||||
}
|
||||
users, departments, chats := splitAccessScopeTargets(targets)
|
||||
if len(users) > 0 {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -43,14 +42,14 @@ var AppsChat = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--session-id is required")
|
||||
return appsValidationParamError("--session-id", "--session-id is required")
|
||||
}
|
||||
// Do not echo --message content in the error (spec §4 redaction).
|
||||
if strings.TrimSpace(rctx.Str("message")) == "" {
|
||||
return output.ErrValidation("--message is required")
|
||||
return appsValidationParamError("--message", "--message is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -36,7 +35,7 @@ var AppsCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return output.ErrValidation("--name is required")
|
||||
return appsValidationParamError("--name", "--name is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -47,6 +48,31 @@ func runAppsShortcut(t *testing.T, sc common.Shortcut, args []string, factory *c
|
||||
return parent.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func requireAppsProblem(t *testing.T, err error, category errs.Category) *errs.Problem {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category {
|
||||
t.Fatalf("error category = %q, want %q", p.Category, category)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func requireAppsValidationProblem(t *testing.T, err error) *errs.Problem {
|
||||
t.Helper()
|
||||
return requireAppsProblem(t, err, errs.CategoryValidation)
|
||||
}
|
||||
|
||||
func requireAppsAPIProblem(t *testing.T, err error) *errs.Problem {
|
||||
t.Helper()
|
||||
return requireAppsProblem(t, err, errs.CategoryAPI)
|
||||
}
|
||||
|
||||
// +create 测试
|
||||
|
||||
func TestAppsCreate_Success(t *testing.T) {
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsDBAuditList 列出数据表的行级审计事件(INSERT/UPDATE/DELETE 的变更追溯)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/audit_list(cursor 分页)。--table 可重复传多张表;--since/--until 多格式时间。
|
||||
// operator 透传 {id,name}(json 还原对象、pretty 取 name);before/after 是条件出现的 JSON
|
||||
// (INSERT 无 before、DELETE 无 after),json 还原成对象。
|
||||
//
|
||||
// 多表查询时,CLI 先用 schema(表是否存在)+ status(审计是否开启)在本地过滤,把不存在 /
|
||||
// 未开启审计的表剔除后再查 audit_list,被剔除的表及原因放进 skipped(服务端不再返该字段)。
|
||||
var AppsDBAuditList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-list",
|
||||
Description: "List row-change audit events for one or more tables (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-list --app-id <app_id> --table orders",
|
||||
"Multiple tables: repeat --table; filter time with --since 7d / --until 2026-04-15.",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
{Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
|
||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(auditListTables(rctx)) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required (at least one table)").WithParam("--table")
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "since", "until")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appAuditListPath(appID)).
|
||||
Desc("List Miaoda app table audit events").
|
||||
Params(buildAuditListParams(rctx, auditListTables(rctx)))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requested := auditListTables(rctx)
|
||||
env := rctx.Str("env")
|
||||
|
||||
// 多表查询:CLI 侧先用 schema(表是否存在)+ status(审计是否开启)过滤,
|
||||
// 不存在 / 未开启审计的表不进 audit_list 查询,单独在 skipped 里给出原因。
|
||||
// 单表查询直接打 audit_list,由后端就 table-not-found / audit-not-enabled 报错。
|
||||
queryTables := requested
|
||||
var skipped []auditSkippedEntry
|
||||
if len(requested) > 1 {
|
||||
queryTables, skipped, err = filterAuditTables(rctx, appID, env, requested)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
// 所有请求表都被过滤掉 → 无可查询表,直接返回空 + skipped 提示,不调 audit_list。
|
||||
if len(queryTables) == 0 {
|
||||
out := map[string]interface{}{"items": []auditLogItem{}, "has_more": false, "skipped": skipped}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
io.WriteString(w, "No audit events found.\n")
|
||||
writeAuditSkipped(w, skipped, len(requested))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
data, err := rctx.CallAPITyped("GET", appAuditListPath(appID), buildAuditListParams(rctx, queryTables), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
items := projectAuditLogItems(data["items"])
|
||||
data["items"] = items
|
||||
// 服务端不再返 skipped;改由 CLI 算出的 skipped 写回输出。
|
||||
if len(skipped) > 0 {
|
||||
data["skipped"] = skipped
|
||||
} else {
|
||||
delete(data, "skipped")
|
||||
}
|
||||
multi := len(requested) > 1
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderAuditListPretty(w, items, skipped, len(requested), multi)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// auditSkippedEntry 是被 CLI 预过滤掉的表及原因(替代已删除的服务端 skipped 字段)。
|
||||
type auditSkippedEntry struct {
|
||||
Table string `json:"table"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// filterAuditTables 用 schema(存在性)+ status(审计开关)把请求表分成「可查询」与「跳过」两组。
|
||||
func filterAuditTables(rctx *common.RuntimeContext, appID, env string, requested []string) ([]string, []auditSkippedEntry, error) {
|
||||
existing, err := fetchExistingTables(rctx, appID, env)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
enabled, err := fetchAuditEnabledTables(rctx, appID, env)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
valid := make([]string, 0, len(requested))
|
||||
var skipped []auditSkippedEntry
|
||||
for _, t := range requested {
|
||||
switch {
|
||||
case !existing[t]:
|
||||
skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "table not found"})
|
||||
case !enabled[t]:
|
||||
skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "audit not enabled"})
|
||||
default:
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
return valid, skipped, nil
|
||||
}
|
||||
|
||||
// fetchExistingTables 翻页拉全量表清单,返回存在表名集合(schema 命令同源接口)。
|
||||
func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||
existing := map[string]bool{}
|
||||
token := ""
|
||||
for {
|
||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
||||
if token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appTablesPath(appID), params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, it := range asMapSlice(data["items"]) {
|
||||
if name := common.GetString(it, "name"); name != "" {
|
||||
existing[name] = true
|
||||
}
|
||||
}
|
||||
token = common.GetString(data, "page_token")
|
||||
if data["has_more"] != true || token == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
||||
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enabled := map[string]bool{}
|
||||
for _, it := range asMapSlice(data["items"]) {
|
||||
if it["enabled"] == true {
|
||||
if name := common.GetString(it, "table"); name != "" {
|
||||
enabled[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// asMapSlice 把 interface{}([]interface{})里的每个 map 元素取出,非 map 丢弃。
|
||||
func asMapSlice(raw interface{}) []map[string]interface{} {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// auditListTables 取 --table 切片,trim 去空。
|
||||
func auditListTables(rctx *common.RuntimeContext) []string {
|
||||
out := make([]string, 0)
|
||||
for _, t := range rctx.StrSlice("table") {
|
||||
if v := strings.TrimSpace(t); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
||||
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": rctx.Str("env"),
|
||||
"tables": strings.Join(tables, ","),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
}
|
||||
}
|
||||
addStr("since", "since")
|
||||
addStr("until", "until")
|
||||
addStr("page-token", "page_token")
|
||||
return params
|
||||
}
|
||||
|
||||
type auditLogItem struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventTime string `json:"event_time"`
|
||||
TargetTable string `json:"target_table"`
|
||||
Type string `json:"type"`
|
||||
Operator *operatorRef `json:"operator,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Before interface{} `json:"before,omitempty"`
|
||||
After interface{} `json:"after,omitempty"`
|
||||
}
|
||||
|
||||
// projectAuditLogItems 把服务端原始审计事件投影为白名单 auditLogItem(operator 解析、before/after 还原成对象)。
|
||||
func projectAuditLogItems(raw interface{}) []auditLogItem {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]auditLogItem, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := auditLogItem{
|
||||
EventID: common.GetString(m, "event_id"),
|
||||
EventTime: common.GetString(m, "event_time"),
|
||||
TargetTable: common.GetString(m, "target_table"),
|
||||
Type: common.GetString(m, "type"),
|
||||
Operator: parseOperator(common.GetString(m, "operator")),
|
||||
Summary: common.GetString(m, "summary"),
|
||||
}
|
||||
// before/after 条件出现:INSERT 无 before、DELETE 无 after。JSON 字符串 → 还原对象。
|
||||
if b := common.GetString(m, "before"); b != "" {
|
||||
row.Before = safeParseJSON(b)
|
||||
}
|
||||
if a := common.GetString(m, "after"); a != "" {
|
||||
row.After = safeParseJSON(a)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderAuditListPretty 单表 5 列 / 多表 6 列(首列 target_table);末尾列出 skipped 表。
|
||||
func renderAuditListPretty(w io.Writer, items []auditLogItem, skipped []auditSkippedEntry, totalRequested int, multi bool) {
|
||||
if len(items) == 0 {
|
||||
io.WriteString(w, "No audit events found.\n")
|
||||
writeAuditSkipped(w, skipped, totalRequested)
|
||||
return
|
||||
}
|
||||
var headers []string
|
||||
if multi {
|
||||
headers = []string{"target_table", "event_time", "type", "event_id", "operator", "summary"}
|
||||
} else {
|
||||
headers = []string{"event_time", "type", "event_id", "operator", "summary"}
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
cells := []string{dashIfEmpty(it.EventTime), it.Type, it.EventID, operatorName(it.Operator), dashIfEmpty(it.Summary)}
|
||||
if multi {
|
||||
cells = append([]string{dashIfEmpty(it.TargetTable)}, cells...)
|
||||
}
|
||||
rows = append(rows, cells)
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
writeAuditSkipped(w, skipped, totalRequested)
|
||||
}
|
||||
|
||||
// writeAuditSkipped 打 "— Skipped N of M tables: orders (audit not enabled), foo (table not found)"。
|
||||
func writeAuditSkipped(w io.Writer, skipped []auditSkippedEntry, totalRequested int) {
|
||||
if len(skipped) == 0 {
|
||||
return
|
||||
}
|
||||
parts := make([]string, 0, len(skipped))
|
||||
for _, s := range skipped {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", s.Table, s.Reason))
|
||||
}
|
||||
fmt.Fprintf(w, "— Skipped %d of %d tables: %s\n", len(skipped), totalRequested, strings.Join(parts, ", "))
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// 审计保留期合法取值。
|
||||
var auditRetentions = []string{"7d", "30d", "180d", "360d", "forever"}
|
||||
|
||||
const dbAuditSetHint = "verify --app-id and --table; check current config with `lark-cli apps +db-audit-status --app-id <app_id>`"
|
||||
|
||||
// AppsDBAuditEnable 为某张表开启行级审计(变更追溯)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/audit_set,body {table, enabled:true, retention}。--retention 默认 7d。
|
||||
var AppsDBAuditEnable = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-enable",
|
||||
Description: "Enable row-change audit logging for a table",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-enable --app-id <app_id> --table orders --retention 30d",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to enable audit for", Required: true},
|
||||
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Enable table audit").
|
||||
Params(map[string]interface{}{"env": rctx.Str("env")}).
|
||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
retention := rctx.Str("retention")
|
||||
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
||||
defer stop()
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": rctx.Str("env")},
|
||||
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
||||
stop()
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbAuditSetHint)
|
||||
}
|
||||
st := auditSetStatus(data, table)
|
||||
ret := common.GetString(st, "retention")
|
||||
if ret == "" {
|
||||
ret = retention
|
||||
}
|
||||
out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": true, "retention": ret}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Audit enabled for table '%s' (retention: %s)\n", common.GetString(out, "table"), ret)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsDBAuditDisable 关闭某张表的行级审计。
|
||||
//
|
||||
// POST /apps/{app_id}/db/audit_set,body {table, enabled:false}。
|
||||
var AppsDBAuditDisable = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-disable",
|
||||
Description: "Disable row-change audit logging for a table",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-disable --app-id <app_id> --table orders",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to disable audit for", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Disable table audit").
|
||||
Params(map[string]interface{}{"env": rctx.Str("env")}).
|
||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": rctx.Str("env")},
|
||||
map[string]interface{}{"table": table, "enabled": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbAuditSetHint)
|
||||
}
|
||||
st := auditSetStatus(data, table)
|
||||
out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": false}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Audit disabled for table '%s'\n", common.GetString(out, "table"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// auditSetStatus 取响应里的 status 对象(缺失时用入参 table 兜底)。
|
||||
func auditSetStatus(data map[string]interface{}, table string) map[string]interface{} {
|
||||
if st, ok := data["status"].(map[string]interface{}); ok {
|
||||
if common.GetString(st, "table") == "" {
|
||||
st["table"] = table
|
||||
}
|
||||
return st
|
||||
}
|
||||
return map[string]interface{}{"table": table}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsDBAuditStatus 查看数据表的审计开关状态(哪些表开了行级审计、保留期)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/audit_status。--table 指定单表(无记录时占位 enabled=false);
|
||||
// 不指定返回所有已配置表。json 单表返对象、多表返数组;pretty 单表 key/value、多表表格。
|
||||
var AppsDBAuditStatus = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-status",
|
||||
Description: "Show table audit (row-change tracking) status",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-status --app-id <app_id>",
|
||||
"Check one table: --table orders",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appAuditStatusPath(appID)).
|
||||
Desc("Get table audit status").
|
||||
Params(buildAuditStatusParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), buildAuditStatusParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
items := projectAuditStatusItems(data["items"])
|
||||
// 单表查询但后端无记录 → 占位 enabled=false(与 miaoda 一致)。
|
||||
if table != "" && len(items) == 0 {
|
||||
items = []map[string]interface{}{{"table": table, "enabled": false}}
|
||||
}
|
||||
// json:单表返对象、多表返数组。
|
||||
var out interface{}
|
||||
if table != "" && len(items) == 1 {
|
||||
out = items[0]
|
||||
} else {
|
||||
out = map[string]interface{}{"items": items}
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderAuditStatusPretty(w, items, table)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
||||
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": rctx.Str("env")}
|
||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||
params["table"] = t
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// projectAuditStatusItems 透出 {table, enabled, enabled_at?, retention?}。
|
||||
func projectAuditStatusItems(raw interface{}) []map[string]interface{} {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]interface{}{
|
||||
"table": common.GetString(m, "table"),
|
||||
"enabled": m["enabled"] == true,
|
||||
}
|
||||
if v := common.GetString(m, "enabled_at"); v != "" {
|
||||
row["enabled_at"] = v
|
||||
}
|
||||
if v := common.GetString(m, "retention"); v != "" {
|
||||
row["retention"] = v
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderAuditStatusPretty 单表渲染 key/value、多表渲染对齐表格(table/enabled/enabled_at/retention)。
|
||||
func renderAuditStatusPretty(w io.Writer, items []map[string]interface{}, table string) {
|
||||
if len(items) == 0 {
|
||||
io.WriteString(w, "No audit configuration found.\n")
|
||||
return
|
||||
}
|
||||
yesNo := func(m map[string]interface{}) string {
|
||||
if m["enabled"] == true {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
get := func(m map[string]interface{}, k string) string { return dashIfEmpty(common.GetString(m, k)) }
|
||||
// 单表 → key/value
|
||||
if table != "" && len(items) == 1 {
|
||||
it := items[0]
|
||||
renderKeyValuePairs(w, [][2]string{
|
||||
{"table", common.GetString(it, "table")},
|
||||
{"enabled", yesNo(it)},
|
||||
{"enabled_at", get(it, "enabled_at")},
|
||||
{"retention", get(it, "retention")},
|
||||
})
|
||||
return
|
||||
}
|
||||
// 多表 → 表格
|
||||
headers := []string{"table", "enabled", "enabled_at", "retention"}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
rows = append(rows, []string{common.GetString(it, "table"), yesNo(it), get(it, "enabled_at"), get(it, "retention")})
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const (
|
||||
dbAuditStatusURL = "/open-apis/spark/v1/apps/app_x/db/audit_status"
|
||||
dbAuditSetURL = "/open-apis/spark/v1/apps/app_x/db/audit_set"
|
||||
dbAuditListURL = "/open-apis/spark/v1/apps/app_x/db/audit_list"
|
||||
dbTablesListURL = "/open-apis/spark/v1/apps/app_x/tables"
|
||||
)
|
||||
|
||||
// ── audit-status ──
|
||||
|
||||
// TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder 验证单表查询无记录时返回 enabled:false 的占位对象(非数组)。
|
||||
func TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditStatus,
|
||||
[]string{"+db-audit-status", "--app-id", "app_x", "--table", "orders", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// 单表无记录 → 占位对象 enabled:false(不是数组)。
|
||||
var env struct {
|
||||
Data struct {
|
||||
Table string `json:"table"`
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Table != "orders" || env.Data.Enabled {
|
||||
t.Fatalf("expected placeholder {orders,false}, got %+v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditStatus_MultiTablePrettyTable 验证多表 pretty 输出含 enabled/yes/no 列与 retention 值。
|
||||
func TestAppsDBAuditStatus_MultiTablePrettyTable(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"table": "orders", "enabled": true, "enabled_at": "2026-04-15T10:30:00Z", "retention": "30d"},
|
||||
map[string]interface{}{"table": "users", "enabled": false},
|
||||
}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditStatus,
|
||||
[]string{"+db-audit-status", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "enabled") || !strings.Contains(got, "yes") || !strings.Contains(got, "no") || !strings.Contains(got, "30d") {
|
||||
t.Fatalf("pretty table malformed:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── audit-enable / disable ──
|
||||
|
||||
// TestAppsDBAuditEnable_RequiresTableAndValidRetention 验证缺 --table 报必填错、非法 --retention 报 ValidationError。
|
||||
func TestAppsDBAuditEnable_RequiresTableAndValidRetention(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// 缺 --table → cobra required, exit 1
|
||||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --table error")
|
||||
}
|
||||
// 非法 retention → enum 校验 (validation)
|
||||
factory2, stdout2, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "99d", "--as", "user"}, factory2, stdout2)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--retention" {
|
||||
t.Fatalf("Param = %q, want --retention", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditEnable_DryRunAndSuccess 验证 dry-run 发出 enabled:true+retention 的 POST,成功时打印 pretty 确认行。
|
||||
func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) {
|
||||
// dry-run body {table, enabled:true, retention}
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
|
||||
// success
|
||||
factory2, stdout2, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbAuditSetURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": true, "retention": "30d"}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout2.String(), "✓ Audit enabled for table 'orders' (retention: 30d)") {
|
||||
t.Fatalf("pretty: %s", stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditDisable_DryRunAndSuccess 验证 dry-run 发出 enabled:false 的 POST,成功时打印 pretty 确认行。
|
||||
func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditDisable,
|
||||
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body)
|
||||
}
|
||||
|
||||
factory2, stdout2, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbAuditSetURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": false}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditDisable,
|
||||
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout2.String(), "✓ Audit disabled for table 'orders'") {
|
||||
t.Fatalf("pretty: %s", stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── audit-list ──
|
||||
|
||||
// TestAppsDBAuditList_RequiresTable 验证缺 --table 时报必填错误。
|
||||
func TestAppsDBAuditList_RequiresTable(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --table error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditList_DryRunJoinsTables 验证 dry-run 将多个 --table 合并为 tables=orders,users 且归一化 since。
|
||||
func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--since", "7d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" {
|
||||
t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"])
|
||||
}
|
||||
if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") {
|
||||
t.Fatalf("since not normalized: %v", a.Params["since"])
|
||||
}
|
||||
}
|
||||
|
||||
// 单表查询:不预过滤、直接打 audit_list(后端就 not-found/not-enabled 报错),无 skipped。
|
||||
// TestAppsDBAuditList_SingleTableNoPreflight 验证单表查询不预过滤、operator/before/after 还原为对象、无 skipped。
|
||||
func TestAppsDBAuditList_SingleTableNoPreflight(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"has_more": false, "page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{
|
||||
"event_id": "01525", "event_time": "2026-04-16T10:30:00Z", "target_table": "users",
|
||||
"type": "UPDATE", "operator": `{"id":"7311","name":"alice"}`, "summary": "UPDATE 1 field",
|
||||
"before": `{"amount":100}`, "after": `{"amount":999}`,
|
||||
}},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "users", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// operator → 对象;before/after → 还原成对象(非字符串)。
|
||||
for _, want := range []string{`"name": "alice"`, `"before"`, `"amount": 100`, `"after"`, `"amount": 999`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `"skipped"`) {
|
||||
t.Errorf("single-table query must not emit skipped:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, `"before": "{`) {
|
||||
t.Errorf("before should be an object, not a JSON string:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditList_SingleTableEmptyPretty 验证单表无事件时不报错、pretty 打印 "No audit events found." 且无 Skipped。
|
||||
func TestAppsDBAuditList_SingleTableEmptyPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("empty audit list should NOT error (ok read), got %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "No audit events found.") || strings.Contains(got, "Skipped") {
|
||||
t.Fatalf("expected empty, no skipped for single table:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 多表查询:CLI 用 schema(存在性)+ status(审计开关)预过滤,只把有效表传给 audit_list,
|
||||
// 不存在 / 未开启审计的表进 skipped。
|
||||
// TestAppsDBAuditList_MultiTablePreflightFilters 验证多表查询用 schema+status 预过滤,仅传有效表,不存在/未开审计的表进 skipped。
|
||||
func TestAppsDBAuditList_MultiTablePreflightFilters(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// schema:orders/users/carts 存在,ghost 不存在。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbTablesListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||||
map[string]interface{}{"name": "orders"}, map[string]interface{}{"name": "users"}, map[string]interface{}{"name": "carts"},
|
||||
}}},
|
||||
})
|
||||
// status:orders/users 开启审计,carts 未开启。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"table": "orders", "enabled": true}, map[string]interface{}{"table": "users", "enabled": true},
|
||||
map[string]interface{}{"table": "carts", "enabled": false},
|
||||
}}},
|
||||
})
|
||||
// audit_list 只应被传入有效表 orders,users。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditListURL,
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("tables"); got != "orders,users" {
|
||||
t.Errorf("audit_list tables = %q, want orders,users (filtered)", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||||
map[string]interface{}{"event_id": "e1", "event_time": "2026-04-16T10:30:00Z", "target_table": "orders", "type": "INSERT", "summary": "INSERT"},
|
||||
}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--table", "carts", "--table", "ghost", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// skipped:carts(audit not enabled) + ghost(table not found),结构化 {table,reason}。
|
||||
for _, want := range []string{`"skipped"`, `"table": "carts"`, `"reason": "audit not enabled"`, `"table": "ghost"`, `"reason": "table not found"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 多表查询且全部被过滤掉 → 不调 audit_list,直接空 + skipped 提示。
|
||||
// TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery 验证多表全部被过滤时跳过 audit_list 调用,直接输出空结果加 Skipped 提示。
|
||||
func TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbTablesListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||||
map[string]interface{}{"name": "orders"},
|
||||
}}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
// 不注册 audit_list:若被调用会命中未注册请求而报错。
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "ghost1", "--table", "ghost2", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("all-filtered should still succeed (empty), got %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "No audit events found.") || !strings.Contains(got, "Skipped 2 of 2 tables") {
|
||||
t.Fatalf("expected empty + 'Skipped 2 of 2 tables':\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbChangelogHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBChangelogList 列出应用数据库的 DDL 变更记录(建表/改表/索引等结构变更追溯)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/changelog_list(cursor 分页)。过滤:--table、--since/--until(多格式时间)。
|
||||
// --change-id 精确查单条(命中返单条、否则空)。operator 后端以 JSON 字符串透传 {id,name},
|
||||
// json 还原成对象、pretty 只展示 name。
|
||||
var AppsDBChangelogList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-changelog-list",
|
||||
Description: "List a Miaoda app database's DDL change history (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-changelog-list --app-id <app_id>",
|
||||
"Pin a single change with --change-id; filter time with --since 7d / --until 2026-04-15.",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
{Name: "table", Desc: "filter by target table"},
|
||||
{Name: "change-id", Desc: "look up a single change by id (returns that one record only)"},
|
||||
{Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
|
||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "since", "until")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appChangelogListPath(appID)).
|
||||
Desc("List Miaoda app DDL changelog").
|
||||
Params(buildChangelogParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appChangelogListPath(appID), buildChangelogParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
items := projectChangelogItems(data["items"])
|
||||
data["items"] = items
|
||||
changeID := strings.TrimSpace(rctx.Str("change-id"))
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderChangelogPretty(w, items, changeID)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
||||
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": rctx.Str("env"),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
}
|
||||
}
|
||||
addStr("table", "table")
|
||||
addStr("change-id", "change_id")
|
||||
addStr("since", "since")
|
||||
addStr("until", "until")
|
||||
addStr("page-token", "page_token")
|
||||
return params
|
||||
}
|
||||
|
||||
type changelogItem struct {
|
||||
ChangeID string `json:"change_id"`
|
||||
ChangedAt string `json:"changed_at"`
|
||||
Operator *operatorRef `json:"operator,omitempty"`
|
||||
TargetTable string `json:"target_table"`
|
||||
ChangeType string `json:"change_type"`
|
||||
Summary string `json:"summary"`
|
||||
Statement string `json:"statement,omitempty"`
|
||||
}
|
||||
|
||||
// projectChangelogItems 把服务端原始 DDL 变更记录投影为白名单 changelogItem(operator 解析成对象)。
|
||||
func projectChangelogItems(raw interface{}) []changelogItem {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]changelogItem, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, changelogItem{
|
||||
ChangeID: common.GetString(m, "change_id"),
|
||||
ChangedAt: common.GetString(m, "changed_at"),
|
||||
Operator: parseOperator(common.GetString(m, "operator")),
|
||||
TargetTable: common.GetString(m, "target_table"),
|
||||
ChangeType: common.GetString(m, "change_type"),
|
||||
Summary: common.GetString(m, "summary"),
|
||||
Statement: common.GetString(m, "statement"),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderChangelogPretty 6 列:change_id / changed_at / operator(name) / target_table / change_type / summary。
|
||||
func renderChangelogPretty(w io.Writer, items []changelogItem, changeID string) {
|
||||
if len(items) == 0 {
|
||||
if changeID != "" {
|
||||
fmt.Fprintf(w, "No DDL change with id=%s found.\n", changeID)
|
||||
} else {
|
||||
io.WriteString(w, "No DDL changes found.\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
headers := []string{"change_id", "changed_at", "operator", "target_table", "change_type", "summary"}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
rows = append(rows, []string{
|
||||
it.ChangeID,
|
||||
dashIfEmpty(it.ChangedAt),
|
||||
operatorName(it.Operator),
|
||||
dashIfEmpty(it.TargetTable),
|
||||
it.ChangeType,
|
||||
dashIfEmpty(it.Summary),
|
||||
})
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const dbChangelogURL = "/open-apis/spark/v1/apps/app_x/db/changelog_list"
|
||||
|
||||
// TestAppsDBChangelogList_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。
|
||||
func TestAppsDBChangelogList_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", " ", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--app-id" {
|
||||
t.Fatalf("Param = %q, want --app-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize 验证 dry-run 透传 env/table/change_id 过滤参数并将 since 归一化为 RFC3339 UTC。
|
||||
func TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", "app_x", "--env", "dev", "--table", "orders",
|
||||
"--change-id", "01J", "--since", "2026-01-01", "--page-size", "5", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbChangelogURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Params["env"] != "dev" || a.Params["table"] != "orders" || a.Params["change_id"] != "01J" {
|
||||
t.Fatalf("params = %v", a.Params)
|
||||
}
|
||||
if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") {
|
||||
t.Fatalf("since not normalized to RFC3339 UTC: %v", a.Params["since"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBChangelogList_RejectsBadSince 验证不可解析的 --since 报 --since 的 ValidationError。
|
||||
func TestAppsDBChangelogList_RejectsBadSince(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", "app_x", "--since", "notatime", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--since" {
|
||||
t.Fatalf("Param = %q, want --since", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBChangelogList_SuccessParsesOperator 验证成功响应中 operator JSON 串被解析为对象并输出变更字段。
|
||||
func TestAppsDBChangelogList_SuccessParsesOperator(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbChangelogURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"has_more": false, "page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{
|
||||
"change_id": "01J", "changed_at": "2026-04-15T10:30:00Z",
|
||||
"operator": `{"id":"7311","name":"alice"}`, "target_table": "orders",
|
||||
"change_type": "ALTER_TABLE", "summary": "add column", "statement": "ALTER TABLE orders ...",
|
||||
}},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"operator"`, `"name": "alice"`, `"id": "7311"`, `"change_type": "ALTER_TABLE"`, `"statement"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBChangelogList_ChangeIDNotFoundPretty 验证按 --change-id 查询无结果时 pretty 打印 not-found 提示。
|
||||
func TestAppsDBChangelogList_ChangeIDNotFoundPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbChangelogURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", "app_x", "--change-id", "nope", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No DDL change with id=nope found.") {
|
||||
t.Fatalf("expected not-found message, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOperator_Cases 验证 parseOperator 处理合法 JSON、空 name 回退 id、非 JSON 原样、空串返回 nil,以及 operatorName(nil) 为占位符。
|
||||
func TestParseOperator_Cases(t *testing.T) {
|
||||
if op := parseOperator(`{"id":"1","name":"a"}`); op == nil || op.ID != "1" || op.Name != "a" {
|
||||
t.Fatalf("valid: %#v", op)
|
||||
}
|
||||
if op := parseOperator(`{"id":"1","name":""}`); op == nil || op.Name != "1" {
|
||||
t.Fatalf("name fallback to id: %#v", op)
|
||||
}
|
||||
if op := parseOperator("plain-user"); op == nil || op.ID != "plain-user" || op.Name != "plain-user" {
|
||||
t.Fatalf("non-json raw: %#v", op)
|
||||
}
|
||||
if op := parseOperator(""); op != nil {
|
||||
t.Fatalf("empty → nil, got %#v", op)
|
||||
}
|
||||
if operatorName(nil) != "—" {
|
||||
t.Fatalf("nil operatorName should be —")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeParseJSON_Cases 验证 safeParseJSON 合法 JSON 解析为对象、非法 JSON 原样返回字符串。
|
||||
func TestSafeParseJSON_Cases(t *testing.T) {
|
||||
if v := safeParseJSON(`{"a":1}`); v == nil {
|
||||
t.Fatalf("valid json → object")
|
||||
}
|
||||
if v, ok := safeParseJSON("not json").(string); !ok || v != "not json" {
|
||||
t.Fatalf("invalid json → raw string, got %v", v)
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbDataExportMaxRows = 5000
|
||||
const dbDataExportMaxBytes = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
const dbDataExportHint = "verify --app-id and --table; if too large, filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets"
|
||||
|
||||
// AppsDBDataExport 把应用数据表导出到本地文件(csv/json/sql)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/data_export,返回原始字节(非 JSON 信封)。
|
||||
// 行数不随导出文件返回:CLI 原子编排——先查 GetAppTableRecordList 的 total,再导出文件。
|
||||
// 数据格式由 --output 扩展名推断(默认 csv,缺省输出 <table>.csv);上限 5000 行 / 1 MB。
|
||||
var AppsDBDataExport = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-data-export",
|
||||
Description: "Export rows from a Miaoda app table to a local file (csv/json/sql)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-data-export --app-id <app_id> --table orders --output ./orders.csv",
|
||||
"Format follows the --output extension: .csv / .json / .sql (default csv).",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "source table", Required: true},
|
||||
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
||||
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "source db environment"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("table")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required").WithParam("--table")
|
||||
}
|
||||
if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--limit must be a positive integer ≤ %d", dbDataExportMaxRows).WithParam("--limit")
|
||||
}
|
||||
if _, _, err := exportFormatAndOutput(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
format, _, _ := exportFormatAndOutput(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDataExportPath(appID)).
|
||||
Desc("Export Miaoda app table data (raw bytes)").
|
||||
Params(map[string]interface{}{
|
||||
"env": rctx.Str("env"), "table": strings.TrimSpace(rctx.Str("table")),
|
||||
"format": format, "limit": rctx.Int("limit"),
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
format, out, err := exportFormatAndOutput(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 原子编排第 1 步:先查总行数(records 列表的 total),再导出文件。
|
||||
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
||||
total, totalErr := queryExportTotal(rctx, appID, rctx.Str("env"), table)
|
||||
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: appDataExportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{
|
||||
"env": []string{rctx.Str("env")},
|
||||
"table": []string{table},
|
||||
"format": []string{format},
|
||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
||||
}
|
||||
// 成功是原始字节;业务错误网关以 JSON 信封 {code,msg} 返回(以 '{' 开头)。
|
||||
if b := bytes.TrimSpace(resp.RawBody); len(b) > 0 && b[0] == '{' {
|
||||
if _, cerr := rctx.ClassifyAPIResponse(resp); cerr != nil {
|
||||
return withAppsHint(cerr, dbDataExportHint)
|
||||
}
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkServer, "export failed: HTTP %d", resp.StatusCode).WithRetryable(), dbDataExportHint)
|
||||
}
|
||||
body := resp.RawBody
|
||||
if len(body) > dbDataExportMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "export exceeds 1 MB limit (%d bytes); filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets", len(body))
|
||||
}
|
||||
|
||||
saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: int64(len(body)),
|
||||
}, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output")
|
||||
}
|
||||
// 行数取自预查的 total(导出最多 limit 行,故取 min);total 查询失败时按导出内容数行兜底。
|
||||
rows := 0
|
||||
if totalErr == nil {
|
||||
rows = total
|
||||
if lim := rctx.Int("limit"); rows > lim {
|
||||
rows = lim
|
||||
}
|
||||
} else {
|
||||
rows = countDataRows(body, format)
|
||||
}
|
||||
resolved, perr := rctx.FileIO().ResolvePath(out)
|
||||
if perr != nil || resolved == "" {
|
||||
resolved = out
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"table": table, "output": resolved, "format": format,
|
||||
"rows": rows, "size_bytes": saved.Size(),
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Exported %s → %s (%d rows)\n", table, resolved, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
||||
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
||||
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
|
||||
map[string]interface{}{"env": env, "page_size": 1}, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return totalAsInt(raw["total"]), nil
|
||||
}
|
||||
|
||||
// totalAsInt 把 total 解析成 int,兼容 JSON number 与 i64-as-string 两种 wire 形态。
|
||||
func totalAsInt(v interface{}) int {
|
||||
if f, ok := numericAsFloat(v); ok {
|
||||
return int(f)
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// exportFormatAndOutput 由 --output 推断数据格式与落盘路径:
|
||||
// 给了 --output → 取其扩展名定 format(csv/json/sql);未给 → 默认 csv、输出 <table>.csv。
|
||||
func exportFormatAndOutput(rctx *common.RuntimeContext) (format, outPath string, err error) {
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
out := strings.TrimSpace(rctx.Str("output"))
|
||||
if out == "" {
|
||||
return "csv", table + ".csv", nil
|
||||
}
|
||||
f, ferr := resolveDataFormat(filepath.Ext(out), true)
|
||||
if ferr != nil {
|
||||
return "", "", ferr
|
||||
}
|
||||
return f, out, nil
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const dbDataExportURL = "/open-apis/spark/v1/apps/app_x/db/data_export"
|
||||
const dbOrdersRecordsURL = "/open-apis/spark/v1/apps/app_x/tables/orders/records"
|
||||
|
||||
// TestAppsDBDataExport_RequiresTable 验证缺 --table 时报必填错误。
|
||||
func TestAppsDBDataExport_RequiresTable(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// 缺 --table → cobra required-flag, exit 1
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected required-flag error for missing --table")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataExport_RejectsBadLimit 验证越界 --limit(0/-1/5001)均报 --limit 的 ValidationError。
|
||||
func TestAppsDBDataExport_RejectsBadLimit(t *testing.T) {
|
||||
for _, lim := range []string{"0", "-1", "5001"} {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--limit", lim, "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("limit=%s err = %T %v, want *errs.ValidationError", lim, err, err)
|
||||
}
|
||||
if ve.Param != "--limit" {
|
||||
t.Fatalf("limit=%s Param = %q, want --limit", lim, ve.Param)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataExport_RejectsBadOutputExtension 验证不支持的 --output 扩展名(.xml)报校验错误。
|
||||
func TestAppsDBDataExport_RejectsBadOutputExtension(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "dump.xml", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected unsupported-format validation for .xml, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// dry-run:format 跟随 --output 扩展名;缺省 csv。
|
||||
// TestAppsDBDataExport_DryRunFormatFromOutput 验证 dry-run 的 format 参数跟随 --output 扩展名、缺省为 csv,并带 limit。
|
||||
func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) {
|
||||
cases := []struct{ output, wantFmt string }{
|
||||
{"", "csv"}, {"orders.csv", "csv"}, {"orders.json", "json"}, {"dump.sql", "sql"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
args := []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}
|
||||
if c.output != "" {
|
||||
args = append(args, "--output", c.output)
|
||||
}
|
||||
if err := runAppsShortcut(t, AppsDBDataExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbDataExportURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Params["format"] != c.wantFmt || a.Params["table"] != "orders" {
|
||||
t.Errorf("output=%q params.format=%v want %q", c.output, a.Params["format"], c.wantFmt)
|
||||
}
|
||||
if _, ok := a.Params["limit"]; !ok {
|
||||
t.Errorf("dry-run missing limit param")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 成功:先查 records 列表 total 计行,再把原始字节落盘。
|
||||
// TestAppsDBDataExport_SuccessWritesFile 验证成功路径先查 records total 计行、再将导出原始字节落盘并输出 rows/format/table。
|
||||
func TestAppsDBDataExport_SuccessWritesFile(t *testing.T) {
|
||||
dir := chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// 第 1 步:records 列表 total=2(行数来源)。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbOrdersRecordsURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 2, "has_more": false, "items": "[]"}},
|
||||
})
|
||||
// 第 2 步:导出原始字节。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: dbDataExportURL,
|
||||
RawBody: []byte("id,name\n1,a\n2,b\n"),
|
||||
Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
b, err := os.ReadFile(dir + "/orders.csv")
|
||||
if err != nil || string(b) != "id,name\n1,a\n2,b\n" {
|
||||
t.Fatalf("output file wrong: %q err=%v", string(b), err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"format": "csv"`) || !strings.Contains(got, `"table": "orders"`) {
|
||||
t.Fatalf("output json missing fields:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 行数取自 records total,且按 --limit 截顶(min(total, limit))。
|
||||
// TestAppsDBDataExport_RowsFromTotalCappedByLimit 验证行数取 records total 并按 --limit 截顶(total=10000、limit=100 → rows=100)。
|
||||
func TestAppsDBDataExport_RowsFromTotalCappedByLimit(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbOrdersRecordsURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 10000, "has_more": true, "items": "[]"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbDataExportURL,
|
||||
RawBody: []byte("id\n1\n2\n3\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--limit", "100", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"rows": 100`) {
|
||||
t.Fatalf("expected rows capped to limit 100 from total=10000:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// total 查询失败(records 列表报错)→ 回退按导出文件内容数行,不阻断导出。
|
||||
// TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable 验证 records total 查询失败时回退按导出文件内容数行,不阻断落盘。
|
||||
func TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable(t *testing.T) {
|
||||
dir := chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbOrdersRecordsURL,
|
||||
Body: map[string]interface{}{"code": 1254000, "msg": "records unavailable"},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbDataExportURL,
|
||||
RawBody: []byte("id,name\n1,a\n2,b\n3,c\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("export should still succeed via fallback, got %v", err)
|
||||
}
|
||||
b, _ := os.ReadFile(dir + "/orders.csv")
|
||||
if string(b) != "id,name\n1,a\n2,b\n3,c\n" {
|
||||
t.Fatalf("file not written on fallback path: %q", string(b))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"rows": 3`) {
|
||||
t.Fatalf("expected fallback file-count rows:3:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 业务错误:网关回 JSON 信封 {code,msg}(非原始字节)→ typed error,不落盘。
|
||||
// TestAppsDBDataExport_BusinessErrorEnvelope 验证响应为 JSON 错误信封(非原始字节)时返回 typed error 且不落盘。
|
||||
func TestAppsDBDataExport_BusinessErrorEnvelope(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: dbDataExportURL,
|
||||
RawBody: []byte(`{"code":1254043,"msg":"table not found"}`),
|
||||
Headers: http.Header{"Content-Type": []string{"application/json"}},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "nope", "--output", "nope.csv", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
if _, statErr := os.Stat("nope.csv"); statErr == nil {
|
||||
t.Fatalf("error path must not write the output file")
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbDataImportMaxBytes = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
const dbDataImportHint = "verify --app-id and --table; data file must be .csv/.json and ≤1 MB — split larger files and import in batches"
|
||||
|
||||
// AppsDBDataImport 把本地 csv/json 文件直传到应用数据表(high-risk-write)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/data_import,multipart 表单:file_name + 可选 table + 文件本体(与
|
||||
// +file-upload / UploadFileForOpenAPI 一致)。文件的格式解析与转换在服务端 integration 层完成
|
||||
// (按 file_name 扩展名推断 csv/json),CLI 不再本地解析。表名缺省取文件名(去扩展名)。上限 1 MB。
|
||||
var AppsDBDataImport = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-data-import",
|
||||
Description: "Import rows from a local csv/json file into a Miaoda app table",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-data-import --app-id <app_id> --file ./orders.csv --yes",
|
||||
"Table defaults to the file name; override with --table.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
|
||||
{Name: "table", Desc: "target table (default: file name without extension)"},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("file")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
|
||||
}
|
||||
// 文件名即可校验格式(服务端按扩展名推断)与推断表名,无需读取内容。
|
||||
if _, err := resolveDataFormat(filepath.Ext(rctx.Str("file")), false); err != nil {
|
||||
return err
|
||||
}
|
||||
// 体积守卫前移到 Validate:用 Stat 先查大小(不读内容),dry-run 也能拦超大文件、且
|
||||
// 在读整个文件进内存之前就失败(对齐 +file-upload)。Stat 失败不在此报错,留给 Execute
|
||||
// 的 ReadInputFile 产出更精确的「文件不存在/越界」错误。
|
||||
if st, serr := rctx.FileIO().Stat(strings.TrimSpace(rctx.Str("file"))); serr == nil && st.Size() > dbDataImportMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", st.Size()).WithParam("--file")
|
||||
}
|
||||
if importTableName(rctx) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot infer target table from file name; specify --table").WithParam("--table")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
fileName := filepath.Base(strings.TrimSpace(rctx.Str("file")))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appDataImportPath(appID)).
|
||||
Desc("Import data file into Miaoda app table (multipart upload)").
|
||||
Params(map[string]interface{}{"env": rctx.Str("env"), "table": importTableName(rctx)}).
|
||||
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
content, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file")
|
||||
}
|
||||
if len(content) > dbDataImportMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", len(content)).WithParam("--file")
|
||||
}
|
||||
fileName := filepath.Base(file)
|
||||
table := importTableName(rctx)
|
||||
|
||||
// multipart:file_name 走表单字段、文件本体走 form-files;env / table 走 query。
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddFile("file", bytes.NewReader(content))
|
||||
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: appDataImportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{"env": []string{rctx.Str("env")}, "table": []string{table}},
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "import request failed").WithCause(err).WithRetryable(), dbDataImportHint)
|
||||
}
|
||||
data, err := rctx.ClassifyAPIResponse(resp)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbDataImportHint)
|
||||
}
|
||||
|
||||
outTable := common.GetString(data, "table")
|
||||
if outTable == "" {
|
||||
outTable = table
|
||||
}
|
||||
rows := int64(0)
|
||||
if f, ok := numericAsFloat(data["rows"]); ok {
|
||||
rows = int64(f)
|
||||
}
|
||||
out := map[string]interface{}{"file": file, "table": outTable, "rows": rows}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Imported %s → table '%s' (%d rows)\n", file, outTable, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// importTableName 取目标表名:--table 优先,否则文件名去扩展名。
|
||||
func importTableName(rctx *common.RuntimeContext) string {
|
||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||
return t
|
||||
}
|
||||
f := strings.TrimSpace(rctx.Str("file"))
|
||||
if f == "" {
|
||||
return ""
|
||||
}
|
||||
base := filepath.Base(f)
|
||||
return strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const dbDataImportURL = "/open-apis/spark/v1/apps/app_x/db/data_import"
|
||||
|
||||
// chdirTemp 切到临时工作目录(--file 走 cwd 内相对路径),返回该目录。
|
||||
func chdirTemp(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
old, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
return dir
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。
|
||||
func TestAppsDBDataImport_RequiresAppID(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", " ", "--file", "orders.csv", "--yes", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--app-id" {
|
||||
t.Fatalf("Param = %q, want --app-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RejectsUnsupportedFormat 验证非 csv/json 文件(.txt)报不支持格式的校验错误。
|
||||
func TestAppsDBDataImport_RejectsUnsupportedFormat(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("data.txt", []byte("x\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "data.txt", "--yes", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected unsupported-format validation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RequiresConfirmation 验证缺 --yes 时报 requires confirmation 错误。
|
||||
func TestAppsDBDataImport_RequiresConfirmation(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("expected confirmation_required, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RejectsOversizeFile 验证超过 1MB 上限的文件报 --file 的 ValidationError。
|
||||
func TestAppsDBDataImport_RejectsOversizeFile(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
// >1MB → size 校验
|
||||
big := append([]byte("id\n"), make([]byte, dbDataImportMaxBytes+1)...)
|
||||
_ = os.WriteFile("big.csv", big, 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "big.csv", "--yes", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected 1MB limit error, got %T %v", err, err)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("Param = %q, want --file", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// dry-run:multipart 上传——file_name + file 走 body,env + table 走 query(table 缺省取文件名)。
|
||||
// TestAppsDBDataImport_DryRunMultipartShape 验证 dry-run 的 multipart 形态:file_name+file 走 body、env+table 走 query 且不再发 format。
|
||||
func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--env", "dev", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbDataImportURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Body["file_name"] != "orders.csv" || a.Body["file"] == nil {
|
||||
t.Fatalf("dry-run body should carry file_name + file: %v", a.Body)
|
||||
}
|
||||
if _, ok := a.Body["format"]; ok {
|
||||
t.Fatalf("format must no longer be sent: %v", a.Body)
|
||||
}
|
||||
if a.Params["env"] != "dev" || a.Params["table"] != "orders" {
|
||||
t.Fatalf("dry-run params (env+table) = %v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
||||
func TestAppsDBDataImport_Success(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id,name\n1,a\n2,b\n"), 0o600)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbDataImportURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"table": "orders", "rows": 2}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--table", "orders", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"table": "orders"`) || !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"file": "orders.csv"`) {
|
||||
t.Fatalf("output missing fields:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_TableDefaultsToFileBasename 验证未传 --table 时表名缺省取文件名去扩展名(customers.json→customers)。
|
||||
func TestAppsDBDataImport_TableDefaultsToFileBasename(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("customers.json", []byte(`[{"id":1}]`), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "customers.json", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Params["table"] != "customers" {
|
||||
t.Fatalf("expected table=customers (from file basename) in params, got %v", env.API[0].Params)
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbEnvMigrateHint = "ensure the app is multi-env (`+db-env-create`) and has pending dev changes; preview with `+db-env-diff`"
|
||||
|
||||
// AppsDBEnvDiff 预览 dev→online 待发布的结构变更(不落地)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_migrate,body {dry_run:true},同步返 {from,to,changes[]}。
|
||||
// 与 +db-env-migrate 同端点、dry_run 区分;预览也需 spark:app:write scope。
|
||||
var AppsDBEnvDiff = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-env-diff",
|
||||
Description: "Preview pending dev→online schema changes (no apply)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-env-diff --app-id <app_id>",
|
||||
"Apply the previewed changes with +db-env-migrate --yes.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Preview dev→online migration").Body(map[string]interface{}{"dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stop := rctx.StartSpinner("Previewing migration diff (dev → online)")
|
||||
defer stop()
|
||||
data, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true})
|
||||
stop()
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(data, "from"), common.GetString(data, "to")
|
||||
changes := projectMigrationChanges(data["changes"])
|
||||
out := map[string]interface{}{"from": from, "to": to, "changes": changes}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderMigrationDiff(w, from, to, changes)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsDBEnvMigrate 把 dev 的待发布结构变更发布到 online(异步,CLI 轮询至完成)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_migrate,body {dry_run:false} → task_id,轮询 env_migrate_status
|
||||
// 至 success;后端 status:applied,CLI 对外统一呈现 migrated。high-risk-write。
|
||||
var AppsDBEnvMigrate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-env-migrate",
|
||||
Description: "Publish pending dev→online schema changes (irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-env-migrate --app-id <app_id> --yes",
|
||||
"Preview first with +db-env-diff.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Apply dev→online migration").Body(map[string]interface{}{"dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||||
taskID := common.GetString(submit, "task_id")
|
||||
applied := intFromAny(submit["changes_applied"])
|
||||
if applied == 0 {
|
||||
applied = len(projectMigrationChanges(submit["changes"]))
|
||||
}
|
||||
// 有 task_id → 异步,轮询至终态;无 task_id(同步完成)则直接用 submit 结果。
|
||||
if taskID != "" {
|
||||
final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
case "success", "applied", "migrated":
|
||||
return true, nil
|
||||
case "failed":
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", migrateFailMsg(d, taskID)), dbEnvMigrateHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
if n := intFromAny(final["changes_applied"]); n > 0 {
|
||||
applied = n
|
||||
}
|
||||
}
|
||||
stop() // clear spinner before printing the result
|
||||
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Migrated %s → %s (%d changes)\n", from, to, applied)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type migrationChange struct {
|
||||
Type string `json:"type"`
|
||||
Table string `json:"table"`
|
||||
Statement string `json:"statement"`
|
||||
}
|
||||
|
||||
// projectMigrationChanges 把服务端原始变更项投影为白名单 migrationChange(type/table/statement)。
|
||||
func projectMigrationChanges(raw interface{}) []migrationChange {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]migrationChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
out = append(out, migrationChange{
|
||||
Type: common.GetString(m, "type"),
|
||||
Table: common.GetString(m, "table"),
|
||||
Statement: common.GetString(m, "statement"),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderMigrationDiff 渲染 dev→online 待发布变更:无变更打提示,否则逐条打 statement。
|
||||
func renderMigrationDiff(w io.Writer, from, to string, changes []migrationChange) {
|
||||
if len(changes) == 0 {
|
||||
fmt.Fprintf(w, "No pending changes from %s to %s.\n", from, to)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s → %s (%d changes):\n\n", from, to, len(changes))
|
||||
for _, c := range changes {
|
||||
fmt.Fprintf(w, " %s\n", c.Statement)
|
||||
}
|
||||
}
|
||||
|
||||
// migrateFailMsg 取发布失败信息:优先服务端 error_message,缺失则用带 task_id 的兜底文案。
|
||||
func migrateFailMsg(d map[string]interface{}, taskID string) string {
|
||||
if m := common.GetString(d, "error_message"); m != "" {
|
||||
return m
|
||||
}
|
||||
return fmt.Sprintf("migration apply failed (task_id=%s)", taskID)
|
||||
}
|
||||
|
||||
// intFromAny 把 JSON number / json.Number 转 int(计数用)。
|
||||
func intFromAny(v interface{}) int {
|
||||
if f, ok := numericAsFloat(v); ok {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const (
|
||||
dbEnvMigrateURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate"
|
||||
dbEnvMigrateStatusURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate_status"
|
||||
dbRecoveryURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery"
|
||||
dbRecoveryDiffURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_diff_status"
|
||||
dbRecoveryApplyURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_apply_status"
|
||||
dbQuotaURL = "/open-apis/spark/v1/apps/app_x/db/quota"
|
||||
)
|
||||
|
||||
// ── env-diff ──
|
||||
|
||||
// TestAppsDBEnvDiff_DryRunBody 校验 dry-run 请求体:POST env_migrate 且 dry_run=true。
|
||||
func TestAppsDBEnvDiff_DryRunBody(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBEnvDiff,
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbEnvMigrateURL || a.Body["dry_run"] != true {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvDiff_SuccessRendersChanges 验证 pretty 输出渲染出 dev → online 变更摘要及 DDL 语句。
|
||||
func TestAppsDBEnvDiff_SuccessRendersChanges(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"from": "dev", "to": "online",
|
||||
"changes": []interface{}{
|
||||
map[string]interface{}{"type": "ALTER_TABLE", "table": "orders", "statement": "ALTER TABLE orders ADD COLUMN note text"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvDiff,
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "dev → online (1 changes)") || !strings.Contains(got, "ALTER TABLE orders ADD COLUMN note text") {
|
||||
t.Fatalf("pretty diff malformed:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvDiff_EmptyChanges 验证无变更时 pretty 输出"无待发布变更"提示。
|
||||
func TestAppsDBEnvDiff_EmptyChanges(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "changes": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvDiff,
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No pending changes from dev to online.") {
|
||||
t.Fatalf("expected empty message, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── env-migrate ──
|
||||
|
||||
// TestAppsDBEnvMigrate_DryRunBody 校验 migrate 的 dry-run 请求体里 dry_run=false(真实迁移)。
|
||||
func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["dry_run"] != false {
|
||||
t.Fatalf("dry-run body=%v (want dry_run:false)", env.API[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
||||
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbEnvMigrateStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "applied", "changes_applied": 3}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "✓ Migrated dev → online (3 changes)") {
|
||||
t.Fatalf("pretty: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
||||
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbEnvMigrateStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "failed", "error_message": "lock timeout"}},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("got %T %v, want API/server_error typed error", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "lock timeout") {
|
||||
t.Fatalf("Message = %q, want it to contain 'lock timeout'", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+db-env-diff") {
|
||||
t.Fatalf("Hint = %q, want the db-env-migrate recovery hint", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvMigrate_RequiresConfirmation 验证 high-risk-write 无 --yes 时被确认门拦截。
|
||||
func TestAppsDBEnvMigrate_RequiresConfirmation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// high-risk-write 无 --yes → 应被确认门拦截(非 0 退出)。
|
||||
if err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected confirmation gate without --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// ── recovery-diff ──
|
||||
|
||||
// TestAppsDBRecoveryDiff_RequiresTarget 验证缺少 --target 时报必填错误。
|
||||
func TestAppsDBRecoveryDiff_RequiresTarget(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --target error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryDiff_DryRunNormalizesTarget 验证 dry-run 走 POST env_recovery 且 --target 被归一化为 RFC3339 UTC。
|
||||
func TestAppsDBRecoveryDiff_DryRunNormalizesTarget(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2026-04-15", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbRecoveryURL || a.Body["dry_run"] != true {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
if s, _ := a.Body["target"].(string); !strings.HasSuffix(s, "Z") {
|
||||
t.Fatalf("target not normalized to RFC3339 UTC: %v", a.Body["target"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryDiff_SuccessRendersChanges 验证 preview 成功后 pretty 渲染受影响表数、行增删与预估耗时。
|
||||
func TestAppsDBRecoveryDiff_SuccessRendersChanges(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbRecoveryDiffURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"preview_status": "success", "tables_affected": 2, "estimated_seconds": 12,
|
||||
"changes": []interface{}{
|
||||
map[string]interface{}{"table": "orders", "inserted": 5, "deleted": 2},
|
||||
map[string]interface{}{"table": "carts", "action": "restore_table"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"tables affected: 2", "orders: +5 rows, -2 rows", "carts: table will be restored", "estimated time: ~12s"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryDiff_PreviewFailed 验证 preview_status=failed 时返回 API/server_error,携带 message 与 PITR window hint。
|
||||
func TestAppsDBRecoveryDiff_PreviewFailed(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbRecoveryDiffURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_status": "failed", "error_message": "snapshot expired"}},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("got %T %v, want API/server_error typed error", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "snapshot expired") {
|
||||
t.Fatalf("Message = %q, want it to contain 'snapshot expired'", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "PITR window") {
|
||||
t.Fatalf("Hint = %q, want the db-recovery recovery hint", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// ── recovery-apply ──
|
||||
|
||||
// TestAppsDBRecoveryApply_NoChangesShortCircuits 验证 status=no_changes 时短路输出"已是该状态",不再轮询。
|
||||
func TestAppsDBRecoveryApply_NoChangesShortCircuits(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "no_changes"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryApply,
|
||||
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No changes — database is already at this state.") {
|
||||
t.Fatalf("expected no-changes short-circuit, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryApply_AsyncPollSuccess 验证 running → 轮询 success 后 pretty 输出恢复完成及耗时。
|
||||
func TestAppsDBRecoveryApply_AsyncPollSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "running"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbRecoveryApplyURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "success", "restore_time_sec": 8}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryApply,
|
||||
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "✓ Database restored to") || !strings.Contains(stdout.String(), "(8s elapsed)") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryApply_RequiresConfirmation 验证无 --yes 时被确认门拦截。
|
||||
func TestAppsDBRecoveryApply_RequiresConfirmation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryApply,
|
||||
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected confirmation gate without --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// ── quota-get ──
|
||||
|
||||
// TestAppsDBQuotaGet_WithQuotaPretty 验证已对接配额时 pretty 渲染存储用量、百分比及 tables/views 数。
|
||||
func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbQuotaURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"storage_used_bytes": 1048576, "storage_quota_bytes": 10485760, "usage_percent": 10.0,
|
||||
"tables": 4, "views": 1,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"Storage", "(10.0%)", "Tables", "4", "Views", "1"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
||||
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbQuotaURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"storage_used_bytes": 2048, "storage_quota_bytes": 0, "tables": 2, "views": 0,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if strings.Contains(got, "storage_quota_bytes") || strings.Contains(got, "usage_percent") {
|
||||
t.Fatalf("quota fields should be omitted when not provisioned:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "storage_used_bytes") || !strings.Contains(got, "\"tables\"") {
|
||||
t.Fatalf("expected used + tables retained:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectDbQuota_WhitelistsFields 验证 projectDbQuota 白名单投影:只保留 used/tables/views(及配额已对接时的
|
||||
// quota/usage_percent),后端额外字段不透传。
|
||||
func TestProjectDbQuota_WhitelistsFields(t *testing.T) {
|
||||
out := projectDbQuota(map[string]interface{}{
|
||||
"storage_used_bytes": 2048, "storage_quota_bytes": float64(0), "usage_percent": float64(0),
|
||||
"tables": 2, "views": 1, "tenant_key": "leak", "internal_shard": "s1",
|
||||
})
|
||||
if _, ok := out["storage_quota_bytes"]; ok {
|
||||
t.Errorf("zero quota should be omitted: %v", out)
|
||||
}
|
||||
if out["storage_used_bytes"] != 2048 || out["tables"] != 2 || out["views"] != 1 {
|
||||
t.Errorf("whitelisted fields should be kept: %v", out)
|
||||
}
|
||||
for _, leaked := range []string{"tenant_key", "internal_shard"} {
|
||||
if _, ok := out[leaked]; ok {
|
||||
t.Errorf("non-whitelisted field %q must be dropped: %v", leaked, out)
|
||||
}
|
||||
}
|
||||
|
||||
out2 := projectDbQuota(map[string]interface{}{"storage_used_bytes": 2048, "storage_quota_bytes": float64(4096), "usage_percent": float64(50), "tables": 2})
|
||||
if _, ok := out2["storage_quota_bytes"]; !ok {
|
||||
t.Errorf("non-zero quota should be kept: %v", out2)
|
||||
}
|
||||
if _, ok := out2["usage_percent"]; !ok {
|
||||
t.Errorf("usage_percent should be kept when quota>0: %v", out2)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -32,18 +31,12 @@ import (
|
||||
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
|
||||
//
|
||||
// 失败语义:server 多语句失败仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
|
||||
// 后升级成 typed errs.APIError(CategoryAPI → exit 1),避免 agent 误判 ok:true 假成功。诊断信息
|
||||
// (第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地)写进 message+hint 文案(errs.* 信封扁平、无
|
||||
// detail 容器):失败在用户显式 BEGIN…COMMIT 事务内 → 整批回滚、前序未落库;否则前序语句已逐条
|
||||
// commit、未回滚。rolled_back 语义由 inferRolledBack 按 BEGIN/COMMIT 计数推断。
|
||||
// 后按 partial failure 上报(exit 非 0):stdout 输出 ok:false 数据,带 results /
|
||||
// statement_index / error_code / error_message / rolled_back / note,避免 agent 误判
|
||||
// ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit
|
||||
// 落地,故 rolled_back=false(真机 boe 实证)。
|
||||
//
|
||||
// JSON(成功路径)按 SQL 类型归一化 `data`(不透传后端 result 字符串):
|
||||
// - 单 SELECT → data 是行数组 `[{...}]`(空 → `[]`)
|
||||
// - 单 DML → data = `{command, rows_affected}`
|
||||
// - 单 DDL → data = `{command}`
|
||||
// - 多语句 → data = `[{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]`
|
||||
//
|
||||
// 字段裁剪用框架原生 --jq/-q。
|
||||
// JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。
|
||||
//
|
||||
// Risk: high-risk-write —— SQL 可含 DML/DDL,框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。
|
||||
//
|
||||
@@ -57,7 +50,7 @@ var AppsDBExecute = common.Shortcut{
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +db-execute --app-id <app_id> --sql "SELECT * FROM orders LIMIT 10" --yes`,
|
||||
`Example: lark-cli apps +db-execute --app-id <app_id> --env dev --file ./migration.sql --yes`,
|
||||
"Tip: single SELECT returns data as a row array — filter with --jq, e.g. -q '.data[].id'",
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.results[].sql_type'",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
@@ -76,19 +69,27 @@ var AppsDBExecute = common.Shortcut{
|
||||
sql := strings.TrimSpace(rctx.Str("sql"))
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
if sql != "" && file != "" {
|
||||
return output.ErrValidation("--sql and --file are mutually exclusive")
|
||||
return appsValidationError("--sql and --file are mutually exclusive").
|
||||
WithParams(
|
||||
appsInvalidParam("--sql", "mutually exclusive with --file"),
|
||||
appsInvalidParam("--file", "mutually exclusive with --sql"),
|
||||
)
|
||||
}
|
||||
if file != "" {
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--file: %v", err)
|
||||
return appsValidationParamError("--file", "--file: %v", err).WithCause(err)
|
||||
}
|
||||
// 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。
|
||||
rctx.Cmd.Flags().Set("sql", string(data))
|
||||
sql = strings.TrimSpace(string(data))
|
||||
}
|
||||
if sql == "" {
|
||||
return output.ErrValidation("one of --sql or --file is required (use --sql - to read stdin)")
|
||||
return appsValidationError("one of --sql or --file is required (use --sql - to read stdin)").
|
||||
WithParams(
|
||||
appsInvalidParam("--sql", "one of --sql or --file is required"),
|
||||
appsInvalidParam("--file", "one of --sql or --file is required"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -112,27 +113,24 @@ var AppsDBExecute = common.Shortcut{
|
||||
return withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get --app-id "+appID+" --table <table>`; for day-to-day debugging target the dev database with `--env dev`")
|
||||
}
|
||||
|
||||
// server `result: string` 内嵌结构化数组 —— CLI 解出来后按 SQL 类型归一化成 PRD 形态,
|
||||
// server `result: string` 内嵌结构化数组 —— CLI 解出来放进 envelope 的 data.results,
|
||||
// 让 json/pretty 路径都基于同一份反序列化产物渲染。
|
||||
stmts := parseSQLResult(common.GetString(raw, "result"))
|
||||
// JSON data 形态(不再透传后端 result 字符串):
|
||||
// - 单 SELECT → data 是行数组 [{...}](空 → [])
|
||||
// - 单 DML → data = {command, rows_affected}
|
||||
// - 单 DDL → data = {command}
|
||||
// - 多语句 → data = [{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]
|
||||
// 字段裁剪走框架原生 --jq/-q(不引入 miaoda 的 --json <fields>)。
|
||||
// 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出直接报错
|
||||
// (而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。
|
||||
data := shapeSQLData(stmts)
|
||||
// 注意:data.results 在 json(默认)路径下原样透出全部行,CLI 侧不再二次截断。
|
||||
// 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出会直接
|
||||
// 返报错(而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。
|
||||
data := map[string]interface{}{"results": stmts}
|
||||
|
||||
// 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。
|
||||
// 升级成 typed api_error(exit 非 0),别让 agent 误判 ok:true 假成功。
|
||||
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout(人看),再返回 error(envelope→stderr)。
|
||||
// 已落地的前序语句 + 失败语句构成 partial failure:逐条结果作为 ok:false 数据
|
||||
// 留在 stdout(机器可读)+ 非零退出信号,别让 agent 误判 ok:true 假成功。
|
||||
// pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope),仅返回退出信号。
|
||||
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
|
||||
if rctx.Format == "pretty" {
|
||||
renderSQLPretty(rctx.IO().Out, stmts)
|
||||
return output.PartialFailure(output.ExitAPI)
|
||||
}
|
||||
return sqlStatementError(stmts, errIdx, errStmt)
|
||||
return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil)
|
||||
}
|
||||
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
@@ -142,70 +140,6 @@ var AppsDBExecute = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// shapeSQLData 把解析出的 statements 归一化成 PRD 约定的 JSON `data` 形态:
|
||||
// - 无语句 → [](空数组)
|
||||
// - 单条语句 → singleStatementJSON(SELECT 是行数组、DML/DDL 是对象)
|
||||
// - 多条语句 → []multiStatementElement(每条统一成 {command,...} 对象,SELECT 行放 rows)
|
||||
//
|
||||
// 不再透传后端 result 字符串(旧形态 data.results[].data 是 JSON 字符串,对 agent 不友好)。
|
||||
func shapeSQLData(stmts []map[string]interface{}) interface{} {
|
||||
if len(stmts) == 0 {
|
||||
return []interface{}{}
|
||||
}
|
||||
if len(stmts) == 1 {
|
||||
return singleStatementJSON(stmts[0])
|
||||
}
|
||||
out := make([]interface{}, 0, len(stmts))
|
||||
for _, s := range stmts {
|
||||
out = append(out, multiStatementElement(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// singleStatementJSON 单条语句的 PRD JSON 形态:
|
||||
// - SELECT → 行数组(空 → [])
|
||||
// - DML → {command, rows_affected}
|
||||
// - DDL / OK / 其它 → {command}
|
||||
func singleStatementJSON(s map[string]interface{}) interface{} {
|
||||
sqlType := common.GetString(s, "sql_type")
|
||||
switch {
|
||||
case sqlType == "SELECT":
|
||||
return selectRows(s)
|
||||
case isDMLType(sqlType):
|
||||
return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])}
|
||||
default:
|
||||
return map[string]interface{}{"command": sqlType}
|
||||
}
|
||||
}
|
||||
|
||||
// multiStatementElement 多语句里单条的 PRD JSON 形态:与单条一致,但 SELECT 包成
|
||||
// {command:"SELECT", rows:[...]}(避免数组里直接嵌套数组造成歧义)。
|
||||
func multiStatementElement(s map[string]interface{}) map[string]interface{} {
|
||||
sqlType := common.GetString(s, "sql_type")
|
||||
switch {
|
||||
case sqlType == "SELECT":
|
||||
return map[string]interface{}{"command": "SELECT", "rows": selectRows(s)}
|
||||
case isDMLType(sqlType):
|
||||
return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])}
|
||||
default:
|
||||
return map[string]interface{}{"command": sqlType}
|
||||
}
|
||||
}
|
||||
|
||||
// selectRows 把 SELECT statement 的 data 字段(行 JSON 数组字符串)解析成行数组;
|
||||
// 空 / 非法一律返回非 nil 的空数组(保证 JSON 序列化成 [] 而非 null)。
|
||||
func selectRows(s map[string]interface{}) []map[string]interface{} {
|
||||
dataJSON := strings.TrimSpace(common.GetString(s, "data"))
|
||||
if dataJSON == "" || dataJSON == "null" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(dataJSON), &rows); err != nil || rows == nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// findErrorSentinel 在 statements 里找 ERROR 哨兵(server 失败时追加在失败语句位置)。
|
||||
// 返回失败语句下标(0-based)、该 ERROR statement、是否命中。
|
||||
func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interface{}, bool) {
|
||||
@@ -217,48 +151,28 @@ func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interfac
|
||||
return 0, nil, false
|
||||
}
|
||||
|
||||
// sqlStatementError 把 ERROR 哨兵升级成 typed errs.APIError(CategoryAPI → exit 1)。
|
||||
// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据。
|
||||
//
|
||||
// 多语句失败的诊断信息——第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地——都写进
|
||||
// message + hint 的人类可读文案(errs.* 信封是扁平字段、不带结构化 detail 容器)。文案对齐
|
||||
// miaoda-cli(src/cli/handlers/db/sql.ts、src/api/db/api.ts):
|
||||
// - message 末尾 "(at statement N of M)" 给出失败位置;
|
||||
// - hint 由 inferRolledBack 推断(实测后端把 BEGIN/COMMIT 也作为 statement 返回):
|
||||
// 失败仍在用户显式事务内 → 服务端整批回滚,用 miaoda 原句 "Transaction rolled back; no changes persisted.";
|
||||
// 否则前序语句已逐条 commit、未回滚(flat 信封无逐句 breakdown,故 hint 简述前序已落地 + 从失败处续跑)。
|
||||
func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error {
|
||||
// CLI 永远 DBA 模式(transactional=false),真机 boe 实证:失败语句之前的语句已逐条 auto-commit
|
||||
// 落地,不存在外层事务回滚。因此 rolled_back=false、results 含全部逐条结果(ERROR 哨兵在
|
||||
// 失败位置),note 提示用户别整批重跑(否则会重复写入)。
|
||||
func sqlStatementFailurePayload(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) map[string]interface{} {
|
||||
code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
|
||||
stmtNo := errIdx + 1 // 1-based 给人看
|
||||
fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts))
|
||||
|
||||
var hint string
|
||||
switch {
|
||||
case inferRolledBack(stmts[:errIdx]):
|
||||
hint = "Transaction rolled back; no changes persisted."
|
||||
case errIdx > 0:
|
||||
hint = fmt.Sprintf("Earlier statements were committed and not rolled back; fix statement %d and re-run the remaining statements.", stmtNo)
|
||||
default:
|
||||
hint = "No statements were applied; fix the SQL and re-run."
|
||||
note := "no statements were applied; fix the SQL and re-run."
|
||||
if errIdx > 0 {
|
||||
note = fmt.Sprintf(
|
||||
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
|
||||
errIdx, stmtNo)
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "%s", fullMsg).WithCode(code).WithHint("%s", hint)
|
||||
}
|
||||
|
||||
// inferRolledBack 推断失败时是否处于用户显式事务内(→ 服务端整批回滚)。
|
||||
// 遍历已完成语句的 sql_type:BEGIN/START TRANSACTION +1,COMMIT/ROLLBACK/END -1;
|
||||
// 结束 depth>0 说明事务还开着、已被服务端回滚。对齐 miaoda-cli inferRolledBack。
|
||||
func inferRolledBack(completed []map[string]interface{}) bool {
|
||||
depth := 0
|
||||
for _, s := range completed {
|
||||
switch strings.ToUpper(strings.TrimSpace(common.GetString(s, "sql_type"))) {
|
||||
case "BEGIN", "START TRANSACTION", "START_TRANSACTION":
|
||||
depth++
|
||||
case "COMMIT", "ROLLBACK", "END":
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"results": stmts,
|
||||
"statement_index": errIdx,
|
||||
"error_code": code,
|
||||
"error_message": fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)),
|
||||
"rolled_back": false,
|
||||
"note": note,
|
||||
}
|
||||
return depth > 0
|
||||
}
|
||||
|
||||
// parseErrorSentinel 解析 ERROR 哨兵的 data(`{code,message}` JSON),返回数值 code 与 message。
|
||||
@@ -440,10 +354,10 @@ func renderMultiStatementPretty(w io.Writer, stmts []map[string]interface{}) {
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if failedIdx >= 0 {
|
||||
// CLI 永远传 transactional=false,失败语句之前的语句已逐条 commit 落地、不会整批回滚——
|
||||
// 如实告诉用户,避免整批重跑导致重复写入。
|
||||
// CLI 永远 DBA 模式(transactional=false),失败语句之前的语句已 auto-commit 落地,
|
||||
// 不存在整批回滚 —— 如实告诉用户,避免整批重跑导致重复写入。
|
||||
if successCount > 0 {
|
||||
fmt.Fprintf(w, "(statement %d failed; %d statement%s before it committed and not rolled back)\n",
|
||||
fmt.Fprintf(w, "(statement %d failed; %d statement%s before it already applied — DBA mode auto-commits each)\n",
|
||||
failedIdx+1, successCount, plural(int64(successCount)))
|
||||
} else {
|
||||
fmt.Fprintf(w, "(statement %d failed; no statements applied)\n", failedIdx+1)
|
||||
@@ -547,7 +461,6 @@ func isDMLType(sqlType string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// dmlVerb 把 DML sql_type 映射成过去分词动词:INSERT→inserted / UPDATE→updated / DELETE→deleted / MERGE→merged,未知 → affected。
|
||||
func dmlVerb(sqlType string) string {
|
||||
switch strings.ToUpper(sqlType) {
|
||||
case "INSERT":
|
||||
@@ -562,7 +475,6 @@ func dmlVerb(sqlType string) string {
|
||||
return "affected"
|
||||
}
|
||||
|
||||
// plural 返回英文复数后缀:n==1 时空串,否则 "s"。
|
||||
func plural(n int64) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
|
||||
@@ -5,18 +5,17 @@ package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestAppsDBExecute_SingleSELECTJSONIsRowArray 断言单条 SELECT 的 JSON data 直接是行数组(不再透传 result 字符串)。
|
||||
func TestAppsDBExecute_SingleSELECTJSONIsRowArray(t *testing.T) {
|
||||
func TestAppsDBExecute_SingleSELECTJSONEnvelopeWrapsResults(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -34,130 +33,23 @@ func TestAppsDBExecute_SingleSELECTJSONIsRowArray(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 单 SELECT:data 直接是行数组(不再是 data.results[].data 字符串)
|
||||
// JSON envelope 应该把 result 字符串 parse 之后放进 data.results
|
||||
var env struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
Data struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data) != 1 {
|
||||
t.Fatalf("data = %d rows (want 1)\n%s", len(env.Data), stdout.String())
|
||||
if len(env.Data.Results) != 1 {
|
||||
t.Fatalf("data.results = %d items (want 1)", len(env.Data.Results))
|
||||
}
|
||||
if env.Data[0]["id"] != float64(101) || env.Data[0]["total_cents"] != float64(2500) {
|
||||
t.Fatalf("data[0] = %v, want {id:101,total_cents:2500}", env.Data[0])
|
||||
if env.Data.Results[0]["sql_type"] != "SELECT" {
|
||||
t.Fatalf("results[0].sql_type = %v", env.Data.Results[0]["sql_type"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleDMLJSONShape 断言单条 DML 的 JSON data 形如 {command, rows_affected}。
|
||||
func TestAppsDBExecute_SingleDMLJSONShape(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"INSERT","data":"","affected_rows":3}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "insert", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 单 DML:data = {command, rows_affected}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Command string `json:"command"`
|
||||
RowsAffected int `json:"rows_affected"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Command != "INSERT" || env.Data.RowsAffected != 3 {
|
||||
t.Fatalf("data = %+v, want {command:INSERT, rows_affected:3}", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleDDLJSONShape 断言单条 DDL 的 JSON data 形如 {command}。
|
||||
func TestAppsDBExecute_SingleDDLJSONShape(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"CREATE_TABLE","data":"[]"}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "create", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 单 DDL:data = {command}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Command string `json:"command"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Command != "CREATE_TABLE" {
|
||||
t.Fatalf("data.command = %q, want CREATE_TABLE", env.Data.Command)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_MultiStatementJSONShape 断言多语句的 JSON data 是元素数组,且 SELECT 包成 {command:"SELECT", rows:[...]}。
|
||||
func TestAppsDBExecute_MultiStatementJSONShape(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[` +
|
||||
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
|
||||
`{"sql_type":"SELECT","data":"[{\"id\":999}]","record_count":1}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 多语句:data 是元素数组;SELECT 包成 {command:"SELECT", rows:[...]}
|
||||
var env struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data) != 2 {
|
||||
t.Fatalf("data = %d elements (want 2)\n%s", len(env.Data), stdout.String())
|
||||
}
|
||||
if env.Data[0]["command"] != "INSERT" || env.Data[0]["rows_affected"] != float64(1) {
|
||||
t.Fatalf("data[0] = %v, want {command:INSERT, rows_affected:1}", env.Data[0])
|
||||
}
|
||||
if env.Data[1]["command"] != "SELECT" {
|
||||
t.Fatalf("data[1].command = %v, want SELECT", env.Data[1]["command"])
|
||||
}
|
||||
rows, ok := env.Data[1]["rows"].([]interface{})
|
||||
if !ok || len(rows) != 1 {
|
||||
t.Fatalf("data[1].rows = %v, want 1 row", env.Data[1]["rows"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_DryRunSendsTransactionalFalse 断言 dry-run 发出的请求是 POST、params 带 transactional=false(DBA 模式)且 transactional 不在 body 里。
|
||||
func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
@@ -193,7 +85,6 @@ func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_RejectsEmptySQL 断言 --sql 全空白时校验报错(提示需要 --sql 或 --file)。
|
||||
func TestAppsDBExecute_RejectsEmptySQL(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
@@ -256,7 +147,6 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
|
||||
// 输入用 BOE 真实抓包数据(test_scripts/boe_e2e/run.log)。
|
||||
// ============================================================================
|
||||
|
||||
// TestAppsDBExecute_LegacyWireSingleSelect 断言 legacy 字符串数组 wire 的单 SELECT 能正常渲染表格、不回退到 RAW。
|
||||
func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) {
|
||||
// BOE 实测:SELECT 1 AS x → result: "[\"[{\\\"x\\\":1}]\"]"
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
@@ -288,9 +178,8 @@ func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray 断言 legacy wire 的 SELECT 同样归一化成 PRD 行数组形态。
|
||||
func TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray(t *testing.T) {
|
||||
// 验证 legacy wire 的 SELECT 也归一化成 PRD 行数组形态(data 直接是行)
|
||||
func TestAppsDBExecute_LegacyWireSingleSelectJSONEnvelope(t *testing.T) {
|
||||
// 验证 JSON envelope 也把 legacy result 正确归一化进 data.results
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -308,20 +197,24 @@ func TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray(t *testing.T) {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
Data struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data) != 1 {
|
||||
t.Fatalf("data length = %d, want 1; got: %v", len(env.Data), env.Data)
|
||||
if len(env.Data.Results) != 1 {
|
||||
t.Fatalf("results length = %d, want 1; got: %v", len(env.Data.Results), env.Data.Results)
|
||||
}
|
||||
if env.Data[0]["x"] != float64(1) {
|
||||
t.Fatalf("data[0].x = %v, want 1", env.Data[0]["x"])
|
||||
if env.Data.Results[0]["sql_type"] != "SELECT" {
|
||||
t.Fatalf("results[0].sql_type = %v, want SELECT", env.Data.Results[0]["sql_type"])
|
||||
}
|
||||
if env.Data.Results[0]["record_count"] != float64(1) {
|
||||
t.Fatalf("results[0].record_count = %v, want 1", env.Data.Results[0]["record_count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireMultiSelect 断言 legacy wire 多 SELECT 输出带 Statement N header 与末尾 "✓ N statements executed" 汇总。
|
||||
func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) {
|
||||
// BOE 实测:SELECT 1; SELECT 2 → result: "[\"[{\\\"?column?\\\":1}]\",\"[{\\\"?column?\\\":2}]\"]"
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
@@ -351,7 +244,6 @@ func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireDDLEmptyResult 断言 result 为空字符串时(legacy DDL)pretty 输出 "(empty result)"。
|
||||
func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) {
|
||||
// BOE 实测:CREATE TABLE → result: "" (空字符串,无 rows)
|
||||
// 老 wire 不区分 DDL/DML/无返回,统一标 "ok"
|
||||
@@ -378,7 +270,6 @@ func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireMultiSelectWithRealTable 断言含 CJK / uuid / int 字段的真实表行能正确显示在 pretty 表格里。
|
||||
func TestAppsDBExecute_LegacyWireMultiSelectWithRealTable(t *testing.T) {
|
||||
// BOE 实测真实表抓包(course 表第一行):复杂 JSON 含 CJK / timestamp / uuid 字段
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
@@ -437,7 +328,6 @@ func TestAppsDBExecute_PrettySingleSelectTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyEmptySelect 断言空 SELECT 的 pretty 输出为 "(0 rows)"。
|
||||
func TestAppsDBExecute_PrettyEmptySelect(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -460,7 +350,6 @@ func TestAppsDBExecute_PrettyEmptySelect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettySingleDMLAndDDL 断言单条 DML 渲染 "✓ N row(s) <verb>"、各类 DDL(含细粒度动词)渲染 "✓ DDL executed"。
|
||||
func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -497,7 +386,6 @@ func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyMultiStatementsAllSuccess 断言多语句全成功时逐条 Statement 摘要 + 末尾 "✓ N statements executed"。
|
||||
func TestAppsDBExecute_PrettyMultiStatementsAllSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -567,7 +455,6 @@ func TestAppsDBExecute_PrettyMultiStatementsDDL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel 断言多语句部分失败时 pretty 仍打逐条 ✓/✗ 摘要、声明前序已 commit 未回滚,且返回 typed error、不打成功汇总。
|
||||
func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -599,20 +486,19 @@ func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *t
|
||||
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
|
||||
}
|
||||
}
|
||||
// 非事务(transactional=false)前序语句已逐条 commit 落地,须如实说明「committed and not rolled back」,
|
||||
// 绝不能误报整批回滚。
|
||||
if !strings.Contains(got, "committed and not rolled back") {
|
||||
t.Errorf("non-tx failure must state prior statements committed & not rolled back; got:\n%s", got)
|
||||
// DBA 模式(transactional=false)前序语句已 auto-commit 落地,绝不能误报「rolled back」。
|
||||
if strings.Contains(got, "rolled back") {
|
||||
t.Errorf("DBA mode must NOT claim rollback (prior statements persisted); got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "statements executed") {
|
||||
t.Errorf("failed run should NOT print success summary; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → typed errs.APIError」:
|
||||
// json 默认不再打 ok:true 假成功,而是返回 typed errs.* 错误(type=api / subtype=server_error、
|
||||
// exit=1)。失败位置在 message 的 "(at statement N of M)",前序是否落地/是否回滚写在 hint。
|
||||
// 本例无 BEGIN → 前序逐条 commit、未回滚(hint 含 "committed and not rolled back")。
|
||||
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → partial failure」:
|
||||
// 逐条结果 + statement_index / error_code / rolled_back / note 作为 ok:false 数据落 stdout,
|
||||
// 退出信号是 PartialFailureError(非零 exit)。rolled_back=false 因 CLI 永远 DBA 模式
|
||||
// (真机 boe 实证:失败前的语句已落地)。
|
||||
func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -632,36 +518,64 @@ func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String())
|
||||
t.Fatalf("multi-statement failure must return a partial-failure error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
// json 失败路径不得打成功 envelope。
|
||||
if strings.Contains(stdout.String(), `"ok": true`) {
|
||||
t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
}
|
||||
if p.Code != 1300002 {
|
||||
t.Errorf("code = %d, want 1300002", p.Code)
|
||||
payload := decodePartialFailureData(t, stdout.String())
|
||||
if got := payload["statement_index"]; got != float64(1) {
|
||||
t.Errorf("statement_index = %v, want 1", got)
|
||||
}
|
||||
if !strings.Contains(p.Message, "(at statement 2 of 2)") {
|
||||
t.Errorf("message missing statement locator: %q", p.Message)
|
||||
if got := payload["error_code"]; got != float64(1300002) {
|
||||
t.Errorf("error_code = %v, want 1300002", got)
|
||||
}
|
||||
// 无 BEGIN → 前序逐条 commit、未回滚,语义写在 hint。
|
||||
if !strings.Contains(p.Hint, "committed and not rolled back") {
|
||||
t.Errorf("hint should state prior statements committed & not rolled back: %q", p.Hint)
|
||||
msg, _ := payload["error_message"].(string)
|
||||
if !strings.Contains(msg, "(at statement 2 of 2)") {
|
||||
t.Errorf("error_message missing statement locator: %q", msg)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
|
||||
if got := payload["rolled_back"]; got != false {
|
||||
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", got)
|
||||
}
|
||||
results, _ := payload["results"].([]interface{})
|
||||
if len(results) != 2 {
|
||||
t.Errorf("results length = %d, want 2 (persisted statement + ERROR sentinel)", len(results))
|
||||
}
|
||||
note, _ := payload["note"].(string)
|
||||
if !strings.Contains(note, "already applied") {
|
||||
t.Errorf("note should warn prior statements persisted, got %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// decodePartialFailureData 解析 stdout 上 ok:false 的 partial-failure envelope,返回 data 块。
|
||||
func decodePartialFailureData(t *testing.T, stdoutStr string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdoutStr), &envelope); err != nil {
|
||||
t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, stdoutStr)
|
||||
}
|
||||
if envelope.OK {
|
||||
t.Fatalf("envelope.ok = true, want false on partial failure")
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
t.Fatalf("envelope.data missing; stdout:\n%s", stdoutStr)
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败(server 也返 code:0 + ERROR 哨兵)
|
||||
// 同样升级成 typed error:statement_index=0、completed 空、message 标注 (at statement 1 of 1)。
|
||||
// 同样走 partial failure:statement_index=0、note 说明无语句落地、message 标注 (at statement 1 of 1)。
|
||||
func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -678,92 +592,26 @@ func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("single ERROR sentinel must return a typed error; stdout:\n%s", stdout.String())
|
||||
t.Fatalf("single ERROR sentinel must return a partial-failure error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
payload := decodePartialFailureData(t, stdout.String())
|
||||
msg, _ := payload["error_message"].(string)
|
||||
if !strings.Contains(msg, "(at statement 1 of 1)") {
|
||||
t.Errorf("error_message missing locator: %q", msg)
|
||||
}
|
||||
if !strings.Contains(p.Message, "(at statement 1 of 1)") {
|
||||
t.Errorf("message missing locator: %q", p.Message)
|
||||
if got := payload["statement_index"]; got != float64(0) {
|
||||
t.Errorf("statement_index = %v, want 0", got)
|
||||
}
|
||||
// 第一条就失败、无落地 的语义写在 hint。
|
||||
if !strings.Contains(p.Hint, "No statements were applied") {
|
||||
t.Errorf("hint should state nothing applied: %q", p.Hint)
|
||||
note, _ := payload["note"].(string)
|
||||
if !strings.Contains(note, "no statements were applied") {
|
||||
t.Errorf("note should say nothing was applied, got %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_TransactionFailureRolledBack 钉死「显式事务内失败 → 整批回滚」:
|
||||
// 实测后端把 BEGIN 也作为 statement 返回;completed 含未配对 BEGIN → inferRolledBack 判定回滚。
|
||||
// 回滚语义现写在 hint(miaoda 原句 "Transaction rolled back; no changes persisted."),失败位置在 message。
|
||||
func TestAppsDBExecute_TransactionFailureRolledBack(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
// BOE 实测 wire:BEGIN; CREATE; INSERT(ok); INSERT(dup→ERROR)
|
||||
"result": `[` +
|
||||
`{"sql_type":"BEGIN","data":"[]"},` +
|
||||
`{"sql_type":"CREATE_TABLE","data":"[]"},` +
|
||||
`{"sql_type":"INSERT","data":"[{\"rowCount\":1}]","affected_rows":1},` +
|
||||
`{"sql_type":"ERROR","data":"{\"code\":\"k_dl_1300002\",\"message\":\"duplicate key value violates unique constraint\"}"}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("transaction failure must return a typed error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Message, "(at statement 4 of 4)") {
|
||||
t.Errorf("message missing statement locator: %q", p.Message)
|
||||
}
|
||||
// 事务整批回滚 / 前序未落库 的语义写在 hint(miaoda 原句)。
|
||||
if !strings.Contains(p.Hint, "Transaction rolled back; no changes persisted.") {
|
||||
t.Errorf("hint should state transaction rolled back & nothing persisted: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInferRolledBack_Cases 断言 inferRolledBack 按 BEGIN/COMMIT/ROLLBACK 计数判定失败时事务是否仍开着(即整批回滚)。
|
||||
func TestInferRolledBack_Cases(t *testing.T) {
|
||||
stmt := func(t string) map[string]interface{} { return map[string]interface{}{"sql_type": t} }
|
||||
cases := []struct {
|
||||
name string
|
||||
completed []map[string]interface{}
|
||||
want bool
|
||||
}{
|
||||
{"empty", nil, false},
|
||||
{"autocommit single", []map[string]interface{}{stmt("INSERT")}, false},
|
||||
{"open tx (unmatched BEGIN)", []map[string]interface{}{stmt("BEGIN"), stmt("CREATE_TABLE"), stmt("INSERT")}, true},
|
||||
{"closed tx (BEGIN+COMMIT)", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("COMMIT")}, false},
|
||||
{"reopened tx", []map[string]interface{}{stmt("BEGIN"), stmt("COMMIT"), stmt("BEGIN"), stmt("INSERT")}, true},
|
||||
{"rollback closes tx", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("ROLLBACK")}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := inferRolledBack(c.completed); got != c.want {
|
||||
t.Errorf("inferRolledBack(%s) = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellString_AllKinds 断言 cellString 对 nil/string/bool/整数/小数/对象各类型的字符串化结果。
|
||||
func TestCellString_AllKinds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -787,7 +635,6 @@ func TestCellString_AllKinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodeString_Forms 断言 codeString 处理 nil / "k_dl_xxx" / 纯数字串 / float64 / 不支持类型各形态。
|
||||
func TestCodeString_Forms(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -809,7 +656,6 @@ func TestCodeString_Forms(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDmlVerb_AllVerbs 断言 dmlVerb 对 INSERT/UPDATE/DELETE/MERGE 的动词映射(大小写不敏感),非 DML 返回 affected。
|
||||
func TestDmlVerb_AllVerbs(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"INSERT": "inserted",
|
||||
@@ -825,7 +671,6 @@ func TestDmlVerb_AllVerbs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntOrZero_Cases 断言 intOrZero 对 JSON number 取整、对非数字 / nil 返回 0。
|
||||
func TestIntOrZero_Cases(t *testing.T) {
|
||||
if got := intOrZero(float64(5)); got != 5 {
|
||||
t.Errorf("intOrZero(5)=%d want 5", got)
|
||||
@@ -838,7 +683,6 @@ func TestIntOrZero_Cases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorSummary_Cases 断言 errorSummary 对空 / 非法 JSON / 带 code / 无 code 各情形生成 "message [code]" 文案。
|
||||
func TestErrorSummary_Cases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, in, want string
|
||||
@@ -857,7 +701,6 @@ func TestErrorSummary_Cases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseErrorSentinel_Cases 断言 parseErrorSentinel 解析 ERROR 哨兵 data 得到数值 code 与 message(含空 / 非法 / 空 message 回退)。
|
||||
func TestParseErrorSentinel_Cases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, in string
|
||||
@@ -879,7 +722,6 @@ func TestParseErrorSentinel_Cases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsStructuredResult_Cases 断言 isStructuredResult 仅在首元素含 sql_type 时判为新结构化形态。
|
||||
func TestIsStructuredResult_Cases(t *testing.T) {
|
||||
if !isStructuredResult([]map[string]interface{}{{"sql_type": "SELECT"}}) {
|
||||
t.Error("expected structured=true when sql_type present")
|
||||
@@ -892,7 +734,6 @@ func TestIsStructuredResult_Cases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeLegacyStatement_Cases 断言 normalizeLegacyStatement 把空 / null / 非 JSON 标为 OK、把 rows 数组标为 SELECT 并带 record_count。
|
||||
func TestNormalizeLegacyStatement_Cases(t *testing.T) {
|
||||
t.Run("empty -> OK", func(t *testing.T) {
|
||||
got := normalizeLegacyStatement("")
|
||||
@@ -923,7 +764,6 @@ func TestNormalizeLegacyStatement_Cases(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellString_MarshalFallback 断言 cellString 对 json.Marshal 拒绝的类型(如 complex)回退到 fmt %v。
|
||||
func TestCellString_MarshalFallback(t *testing.T) {
|
||||
// complex128 is not switch-handled and json.Marshal rejects it →
|
||||
// falls back to fmt.Sprintf("%v", v), which is deterministic for complex.
|
||||
@@ -932,7 +772,6 @@ func TestCellString_MarshalFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderSingleStatementPretty_Branches 断言 renderSingleStatementPretty 对 SELECT/ERROR/DML/legacy OK/DDL 各分支的输出。
|
||||
func TestRenderSingleStatementPretty_Branches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -956,7 +795,6 @@ func TestRenderSingleStatementPretty_Branches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderSelectRowsAsTable_Branches 断言 renderSelectRowsAsTable 对空串 / 空数组 / 非法 JSON 回退 / 正常 rows 各分支的输出。
|
||||
func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -978,3 +816,35 @@ func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly pins the pretty
|
||||
// contract on a statement failure: stdout carries only the per-statement
|
||||
// human summary (no JSON envelope stacked after it), and the command still
|
||||
// exits non-zero via the partial-failure signal.
|
||||
func TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"ERROR","data":"{\"code\":\"k_dl_000002\",\"message\":\"syntax error\"}"}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "✗") {
|
||||
t.Fatalf("pretty summary missing failure marker; stdout:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `"ok"`) {
|
||||
t.Fatalf("pretty stdout must not stack a JSON envelope after the summary; stdout:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsDBQuotaGet reports an app's database storage usage and object counts.
|
||||
//
|
||||
// GET /apps/{app_id}/db/quota。storage_quota_bytes / usage_percent 在配额未对接(=0)时
|
||||
// 不输出(与 +file-quota-get 一致);tables / views 始终输出。
|
||||
var AppsDBQuotaGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-quota-get",
|
||||
Description: "Get an app's database storage usage",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-quota-get --app-id <app_id>",
|
||||
"Example: lark-cli apps +db-quota-get --app-id <app_id> --env dev",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDbQuotaPath(appID)).
|
||||
Desc("Get Miaoda app database storage usage").
|
||||
Params(map[string]interface{}{"env": rctx.Str("env")})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": rctx.Str("env")}, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := projectDbQuota(data)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderDbQuotaPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// projectDbQuota 白名单投影 db quota 字段:只保留 storage_used_bytes / tables / views,
|
||||
// 配额已对接时再加 storage_quota_bytes / usage_percent。不透传后端其它字段,避免无用字段消耗上下文。
|
||||
func projectDbQuota(data map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{"storage_used_bytes": data["storage_used_bytes"]}
|
||||
for _, k := range []string{"tables", "views"} {
|
||||
if v, ok := data[k]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
// 配额未对接(storage_quota_bytes=0/缺失)时不输出 quota / usage_percent。
|
||||
if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 {
|
||||
out["storage_quota_bytes"] = data["storage_quota_bytes"]
|
||||
if v, ok := data["usage_percent"]; ok {
|
||||
out["usage_percent"] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderDbQuotaPretty 打 Storage(已用 / 配额 (百分比))与 Tables / Views 行(标签对齐 miaoda-cli)。
|
||||
func renderDbQuotaPretty(w io.Writer, data map[string]interface{}) {
|
||||
used := humanBytes(data["storage_used_bytes"])
|
||||
usage := used
|
||||
if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 {
|
||||
pct := ""
|
||||
if p, ok := numericAsFloat(data["usage_percent"]); ok {
|
||||
pct = fmt.Sprintf(" (%.1f%%)", p)
|
||||
}
|
||||
usage = fmt.Sprintf("%s / %s%s", used, humanBytes(data["storage_quota_bytes"]), pct)
|
||||
}
|
||||
pairs := [][2]string{{"Storage", usage}}
|
||||
if f, ok := numericAsFloat(data["tables"]); ok {
|
||||
pairs = append(pairs, [2]string{"Tables", fmt.Sprintf("%d", int64(f))})
|
||||
}
|
||||
if f, ok := numericAsFloat(data["views"]); ok {
|
||||
pairs = append(pairs, [2]string{"Views", fmt.Sprintf("%d", int64(f))})
|
||||
}
|
||||
renderKeyValuePairs(w, pairs)
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbRecoveryHint = "PITR window is up to 7 days back, limited by your last `+db-env-migrate`; pass --target as a time (e.g. 2h / 2026-04-15 / 2026-04-15T10:00:00Z)"
|
||||
|
||||
// AppsDBRecoveryDiff 预览把数据库恢复到某个时间点会带来的变更(PITR diff,不落地)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:true} → preview_request_id,
|
||||
// 轮询 env_recovery_diff_status 至终态,返回受影响表与行数变化。预览也需 spark:app:write scope。
|
||||
var AppsDBRecoveryDiff = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-recovery-diff",
|
||||
Description: "Preview restoring the database to a point in time (PITR diff)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-recovery-diff --app-id <app_id> --target 2h",
|
||||
"Apply with +db-recovery-apply --target <same> --yes.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := rctx.Str("target")
|
||||
preview, err := runRecoveryPreview(rctx, appID, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := recoveryDiffOutput(target, preview)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderRecoveryDiff(w, target, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsDBRecoveryApply 把数据库恢复到某个时间点(覆盖当前数据,异步,CLI 轮询至完成)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:false};目标=当前态时短路 no_changes,
|
||||
// 否则轮询 env_recovery_apply_status 至 success。high-risk-write。
|
||||
var AppsDBRecoveryApply = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-recovery-apply",
|
||||
Description: "Restore the database to a point in time (overwrites current data, irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-recovery-apply --app-id <app_id> --target 2026-04-15T10:00:00Z --yes",
|
||||
"Preview first with +db-recovery-diff.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := rctx.Str("target")
|
||||
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
// 目标=当前态 → 后端短路 no_changes,不轮询。
|
||||
if strings.ToLower(common.GetString(submit, "status")) == "no_changes" {
|
||||
stop()
|
||||
out := map[string]interface{}{"status": "no_changes", "target": target}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
io.WriteString(w, "No changes — database is already at this state.\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 30*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
case "success", "restored", "ready":
|
||||
return true, nil
|
||||
case "failed":
|
||||
msg := common.GetString(d, "error_message")
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("recovery to %s failed", target)
|
||||
}
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
stop()
|
||||
out := map[string]interface{}{"status": "restored", "target": target}
|
||||
if n := intFromAny(final["restore_time_sec"]); n > 0 {
|
||||
out["restore_time_sec"] = n
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
if n, ok := out["restore_time_sec"].(int); ok {
|
||||
fmt.Fprintf(w, "✓ Database restored to %s (%ds elapsed)\n", target, n)
|
||||
} else {
|
||||
fmt.Fprintf(w, "✓ Database restored to %s\n", target)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// runRecoveryPreview 触发 PITR 预览(dry_run=true)拿 preview_request_id,轮询 diff_status 至终态。
|
||||
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
||||
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
|
||||
if err != nil {
|
||||
return nil, withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
prid := common.GetString(submit, "preview_request_id")
|
||||
if prid == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id")
|
||||
}
|
||||
return pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||
case "success":
|
||||
return true, nil
|
||||
case "failed":
|
||||
msg := common.GetString(d, "error_message")
|
||||
if msg == "" {
|
||||
msg = "recovery preview failed"
|
||||
}
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
type recoveryChange struct {
|
||||
Table string `json:"table"`
|
||||
Inserted interface{} `json:"inserted,omitempty"`
|
||||
Deleted interface{} `json:"deleted,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
DroppedAt string `json:"dropped_at,omitempty"`
|
||||
}
|
||||
|
||||
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
||||
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
||||
arr, _ := preview["changes"].([]interface{})
|
||||
changes := make([]recoveryChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, recoveryChange{
|
||||
Table: common.GetString(m, "table"),
|
||||
Inserted: m["inserted"],
|
||||
Deleted: m["deleted"],
|
||||
Action: common.GetString(m, "action"),
|
||||
DroppedAt: common.GetString(m, "dropped_at"),
|
||||
})
|
||||
}
|
||||
tablesAffected := intFromAny(preview["tables_affected"])
|
||||
if tablesAffected == 0 {
|
||||
tablesAffected = len(changes)
|
||||
}
|
||||
est := intFromAny(preview["estimated_seconds"])
|
||||
if est == 0 {
|
||||
est = 30 // PRD 兜底
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"target": target, "tables_affected": tablesAffected,
|
||||
"changes": changes, "estimated_seconds": est,
|
||||
}
|
||||
}
|
||||
|
||||
// renderRecoveryDiff 渲染 PITR 恢复预览:受影响表数、逐表变化描述及预估耗时;无变更打提示。
|
||||
func renderRecoveryDiff(w io.Writer, target string, out map[string]interface{}) {
|
||||
changes, _ := out["changes"].([]recoveryChange)
|
||||
if len(changes) == 0 {
|
||||
io.WriteString(w, "No changes — database is already at this state.\n")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Recovery preview (→ %s):\n\n", target)
|
||||
fmt.Fprintf(w, " tables affected: %d\n", intFromAny(out["tables_affected"]))
|
||||
for _, c := range changes {
|
||||
fmt.Fprintf(w, " %s: %s\n", c.Table, describeRecoveryChange(c))
|
||||
}
|
||||
fmt.Fprintf(w, "\n estimated time: ~%ds\n", intFromAny(out["estimated_seconds"]))
|
||||
}
|
||||
|
||||
// describeRecoveryChange:schema 动作 或 数据行变化二选一(无 modified,对齐设计)。
|
||||
func describeRecoveryChange(c recoveryChange) string {
|
||||
switch c.Action {
|
||||
case "restore_table":
|
||||
return "table will be restored"
|
||||
case "drop_table":
|
||||
return "table will be dropped"
|
||||
case "alter_table":
|
||||
return "table will be altered"
|
||||
case "unavailable":
|
||||
if c.DroppedAt != "" {
|
||||
return "diff unavailable: " + c.DroppedAt
|
||||
}
|
||||
return "diff unavailable"
|
||||
}
|
||||
parts := make([]string, 0, 2)
|
||||
if n := intFromAny(c.Inserted); n != 0 {
|
||||
parts = append(parts, fmt.Sprintf("+%d rows", n))
|
||||
}
|
||||
if n := intFromAny(c.Deleted); n != 0 {
|
||||
parts = append(parts, fmt.Sprintf("-%d rows", n))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "no changes"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -45,7 +44,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("table")) == "" {
|
||||
return output.ErrValidation("--table is required")
|
||||
return appsValidationParamError("--table", "--table is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -47,11 +47,11 @@ var AppsEnvPull = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: "--app-id is required"}, Param: "app-id"}
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
if err != nil {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
|
||||
return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err)
|
||||
}
|
||||
if err := checkEnvPullTarget(envFile); err != nil {
|
||||
return err
|
||||
@@ -71,7 +71,7 @@ var AppsEnvPull = common.Shortcut{
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
if err != nil {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
|
||||
return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err)
|
||||
}
|
||||
if err := checkEnvPullTarget(envFile); err != nil {
|
||||
return err
|
||||
@@ -120,7 +120,7 @@ func resolveEnvPullTarget(projectPath string) (string, string, error) {
|
||||
if strings.TrimSpace(projectPath) == "" {
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "cannot determine working directory: %v", err).WithCause(err)
|
||||
}
|
||||
projectPath = cwd
|
||||
}
|
||||
@@ -137,13 +137,13 @@ func checkEnvPullTarget(envFile string) error {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("cannot inspect %s: %v", envFile, err)}, Param: "project-path", Cause: err}
|
||||
return appsValidationParamError("--project-path", "cannot inspect %s: %v", envFile, err).WithCause(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file, not a symlink", envFile)}, Param: "project-path"}
|
||||
return appsValidationParamError("--project-path", "target %s must be a regular file, not a symlink", envFile)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file", envFile)}, Param: "project-path"}
|
||||
return appsValidationParamError("--project-path", "target %s must be a regular file", envFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
|
||||
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
|
||||
}
|
||||
|
||||
var skippedKeys []string
|
||||
@@ -203,7 +203,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull
|
||||
}
|
||||
return out, info, skippedKeys, nil
|
||||
default:
|
||||
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
|
||||
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1079,3 +1079,28 @@ func TestEnsureEnvPullParentDir_MkdirError(t *testing.T) {
|
||||
t.Error("MkdirAll over a file component must surface an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractEnvPullVars_MissingEnvVarsIsInternalInvalidResponse pins that a
|
||||
// response without a usable env_vars field classifies as
|
||||
// internal/invalid_response — a broken upstream payload, not a flag problem
|
||||
// the agent should retry with different arguments.
|
||||
func TestExtractEnvPullVars_MissingEnvVarsIsInternalInvalidResponse(t *testing.T) {
|
||||
for name, data := range map[string]map[string]interface{}{
|
||||
"missing": {},
|
||||
"wrong type": {"env_vars": "not-an-object"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, _, _, err := extractEnvPullVars(data)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %s env_vars", name)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
104
shortcuts/apps/apps_errors.go
Normal file
104
shortcuts/apps/apps_errors.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"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 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func appsValidationParamError(param, format string, args ...any) *errs.ValidationError {
|
||||
return appsValidationError(format, args...).WithParam(param)
|
||||
}
|
||||
|
||||
func appsInvalidParam(name, reason string) errs.InvalidParam {
|
||||
return errs.InvalidParam{Name: name, Reason: reason}
|
||||
}
|
||||
|
||||
func appsFailedPreconditionParamError(param, format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...).WithParam(param)
|
||||
}
|
||||
|
||||
func appsFailedPreconditionError(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...)
|
||||
}
|
||||
|
||||
// appsStorageError classifies a local credential/state storage failure
|
||||
// (read, write, delete, list) as internal/storage.
|
||||
func appsStorageError(err error, format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// appsExternalToolError classifies a runtime failure of an external tool the
|
||||
// CLI shells out to (git, npx) as internal/external_tool. The tool output is
|
||||
// carried in the message; recovery guidance goes in the hint.
|
||||
func appsExternalToolError(err error, format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeExternalTool, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// appsSubprocessEnvelopeError classifies a malformed or failed envelope from a
|
||||
// lark-cli subprocess (+git-credential-init / +env-pull) as internal/invalid_response.
|
||||
func appsSubprocessEnvelopeError(format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
|
||||
}
|
||||
|
||||
func appsInputPathError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return appsValidationParamError("--path", "unsafe --path: %s", err).WithCause(err)
|
||||
}
|
||||
return appsValidationParamError("--path", "cannot read --path: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
func appsInputPathEntryError(path string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return appsValidationParamError("--path", "unsafe --path entry %s: %s", path, err).WithCause(err)
|
||||
}
|
||||
return appsValidationParamError("--path", "cannot read --path entry %s: %s", path, err).WithCause(err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
113
shortcuts/apps/apps_errors_test.go
Normal file
113
shortcuts/apps/apps_errors_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
func TestAppsInputPathError_ClassifiesPathValidation(t *testing.T) {
|
||||
cause := errors.New("path escapes working directory")
|
||||
err := appsInputPathError(&fileio.PathValidationError{Err: cause})
|
||||
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "unsafe --path") {
|
||||
t.Fatalf("message = %q, want unsafe --path context", problem.Message)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--path" {
|
||||
t.Fatalf("param = %q, want --path", validationErr.Param)
|
||||
}
|
||||
if !errors.Is(err, fileio.ErrPathValidation) || !errors.Is(err, cause) {
|
||||
t.Fatalf("path validation cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInputPathEntryError_ClassifiesReadFailure(t *testing.T) {
|
||||
cause := errors.New("permission denied")
|
||||
err := appsInputPathEntryError("dist/index.html", cause)
|
||||
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "cannot read --path entry dist/index.html") {
|
||||
t.Fatalf("message = %q, want entry read context", problem.Message)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileIOError_ClassifiesInternalFileIO(t *testing.T) {
|
||||
cause := errors.New("archive writer failed")
|
||||
err := appsFileIOError(cause, "pack failed: %v", cause)
|
||||
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if problem.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeFileIO)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsFileDelete batch-deletes files by remote path(high-risk-write,框架自动注入 --yes 确认)。
|
||||
//
|
||||
// POST /apps/{app_id}/storage/file_batch_remove,body {paths:[...]}。网关把该路由注册为 POST
|
||||
// (DELETE-with-body 不被网关支持,实测 DELETE→404 / POST→200)。后端 results[] 与请求 paths
|
||||
// 顺序一一对应:成功项带 file,失败项带 error_code(CLI 据下标回填 path)。
|
||||
// 部分失败整体仍 ok:true —— 失败项落在 data.results[].error,不翻成非 0 退出码(lark-cli 信封语义)。
|
||||
var AppsFileDelete = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+file-delete",
|
||||
Description: "Delete one or more files by remote path (batch)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +file-delete --app-id <app_id> --path /1858537546760216.png --yes",
|
||||
"Repeat --path for batch delete.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "path", Type: "string_slice", Desc: "remote file path to delete (repeatable)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cleanDeletePaths(rctx)) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--path is required (at least one remote path)").WithParam("--path")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appFileBatchRemovePath(appID)).
|
||||
Desc("Batch delete Miaoda app files").
|
||||
Body(map[string]interface{}{"paths": cleanDeletePaths(rctx)})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := cleanDeletePaths(rctx)
|
||||
data, err := rctx.CallAPITyped("POST", appFileBatchRemovePath(appID), nil, map[string]interface{}{"paths": paths})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results := projectDeleteResults(data["results"], paths)
|
||||
out := map[string]interface{}{"results": results}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderFileDeletePretty(w, results)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// cleanDeletePaths 取 --path 切片,trim 去空。
|
||||
func cleanDeletePaths(rctx *common.RuntimeContext) []string {
|
||||
out := make([]string, 0)
|
||||
for _, p := range rctx.StrSlice("path") {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// projectDeleteResults 把后端 results[] 按下标 zip 回请求 paths,回填 path,
|
||||
// 失败项把 error_code 包成 {code,message} 便于消费。
|
||||
func projectDeleteResults(raw interface{}, inputs []string) []map[string]interface{} {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(inputs))
|
||||
for i, input := range inputs {
|
||||
var r map[string]interface{}
|
||||
if i < len(arr) {
|
||||
r, _ = arr[i].(map[string]interface{})
|
||||
}
|
||||
status := "ok"
|
||||
if r != nil && common.GetString(r, "status") != "" {
|
||||
status = common.GetString(r, "status")
|
||||
}
|
||||
item := map[string]interface{}{"status": status, "path": input}
|
||||
if status == "ok" {
|
||||
if r != nil {
|
||||
if f, ok := r["file"].(map[string]interface{}); ok {
|
||||
item["file_name"] = common.GetString(f, "file_name")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
code := ""
|
||||
if r != nil {
|
||||
code = common.GetString(r, "error_code")
|
||||
}
|
||||
if code == "" {
|
||||
code = "DELETE_FAILED"
|
||||
}
|
||||
item["error"] = map[string]interface{}{
|
||||
"code": code,
|
||||
"message": deleteErrorMessage(code, input),
|
||||
}
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deleteErrorMessage 据 error_code 生成删除失败文案:FILE_NOT_FOUND 提示文件不存在,其余统一删除失败。
|
||||
func deleteErrorMessage(code, path string) string {
|
||||
if code == "FILE_NOT_FOUND" {
|
||||
return fmt.Sprintf("File '%s' does not exist", path)
|
||||
}
|
||||
return fmt.Sprintf("Failed to delete '%s'", path)
|
||||
}
|
||||
|
||||
// renderFileDeletePretty 逐项打 ✓ / ✗,末行汇总 deleted 计数。
|
||||
func renderFileDeletePretty(w io.Writer, results []map[string]interface{}) {
|
||||
okCount := 0
|
||||
for _, r := range results {
|
||||
path := common.GetString(r, "path")
|
||||
if common.GetString(r, "status") == "ok" {
|
||||
fmt.Fprintf(w, "✓ %s\n", path)
|
||||
okCount++
|
||||
continue
|
||||
}
|
||||
code := ""
|
||||
if e, ok := r["error"].(map[string]interface{}); ok {
|
||||
code = common.GetString(e, "code")
|
||||
}
|
||||
fmt.Fprintf(w, "✗ %s (%s)\n", path, code)
|
||||
}
|
||||
fmt.Fprintf(w, "\n%d/%d deleted\n", okCount, len(results))
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const fileDeleteURL = "/open-apis/spark/v1/apps/app_x/storage/file_batch_remove"
|
||||
|
||||
// TestAppsFileDelete_RequiresAppIDAndPath 验证仅含空白的 --path 去空后为空时,Validate 报 --path typed 校验错误。
|
||||
func TestAppsFileDelete_RequiresAppIDAndPath(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// 传入仅含空白的 --path:满足 cobra 的 Required 检查,但 cleanDeletePaths 去空后为空,
|
||||
// 触发 Validate 内的 typed --path 校验。
|
||||
err := runAppsShortcut(t, AppsFileDelete,
|
||||
[]string{"+file-delete", "--app-id", "app_x", "--path", " ", "--yes", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--path" {
|
||||
t.Fatalf("Param = %q, want --path", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// high-risk-write:无 --yes → confirmation_required(exit 10)。
|
||||
func TestAppsFileDelete_RequiresConfirmation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileDelete,
|
||||
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("expected confirmation_required, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileDelete_DryRunSendsPaths 验证 dry-run 输出 POST file_batch_remove,body.paths 按序携带多个 --path。
|
||||
func TestAppsFileDelete_DryRunSendsPaths(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileDelete,
|
||||
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/b.png", "--yes", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != fileDeleteURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
paths, _ := a.Body["paths"].([]interface{})
|
||||
if len(paths) != 2 || paths[0] != "/a.png" || paths[1] != "/b.png" {
|
||||
t.Fatalf("body.paths = %v", a.Body["paths"])
|
||||
}
|
||||
}
|
||||
|
||||
// 部分失败仍 ok:true;results 按下标 zip 回 path;失败项带 error{code,message}。
|
||||
func TestAppsFileDelete_PartialFailureStillOK(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: fileDeleteURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"results": []interface{}{
|
||||
map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png", "path": "/a.png"}},
|
||||
map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsFileDelete,
|
||||
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("partial failure should NOT error (ok:true semantics), got %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
var env struct {
|
||||
Data struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, got)
|
||||
}
|
||||
if len(env.Data.Results) != 2 {
|
||||
t.Fatalf("want 2 results, got %d: %s", len(env.Data.Results), got)
|
||||
}
|
||||
r0, r1 := env.Data.Results[0], env.Data.Results[1]
|
||||
if r0["status"] != "ok" || r0["path"] != "/a.png" {
|
||||
t.Errorf("result[0] = %v", r0)
|
||||
}
|
||||
if r1["status"] != "error" || r1["path"] != "/missing.png" {
|
||||
t.Errorf("result[1] = %v (path must be back-filled by index)", r1)
|
||||
}
|
||||
if e, ok := r1["error"].(map[string]interface{}); !ok || e["code"] != "FILE_NOT_FOUND" {
|
||||
t.Errorf("result[1].error = %v (want code FILE_NOT_FOUND)", r1["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileDelete_PrettySummary 验证 pretty 输出逐项 ✓/✗ 标记并汇总 "1/2 deleted"。
|
||||
func TestAppsFileDelete_PrettySummary(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: fileDeleteURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"results": []interface{}{
|
||||
map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png"}},
|
||||
map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsFileDelete,
|
||||
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"✓ /a.png", "✗ /missing.png (FILE_NOT_FOUND)", "1/2 deleted"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("pretty missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsFileDownload downloads a file to a local path via a signed URL。
|
||||
//
|
||||
// 两步:POST /apps/{app_id}/storage/file_sign 拿 signed_url(presigned,直连对象存储),
|
||||
// 再客户端 GET signed_url 落盘到 --output(默认远端 basename)。不单设 download 接口。
|
||||
var AppsFileDownload = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+file-download",
|
||||
Description: "Download a file to a local path (via a signed URL)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +file-download --app-id <app_id> --path /1858537546760216.png --output ./logo.png",
|
||||
"Example (omit --output): lark-cli apps +file-download --app-id <app_id> --path /1858537546760216.png # saves to ./1858537546760216.png",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "path", Desc: "remote file path", Required: true},
|
||||
{Name: "output", Desc: "local output path (default: remote file basename in cwd)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := requireFilePath(rctx.Str("path"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
remotePath, _ := requireFilePath(rctx.Str("path"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appFileSignPath(appID)).
|
||||
Desc("Sign a download URL, then GET it to --output").
|
||||
Body(map[string]interface{}{"path": remotePath})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remotePath, err := requireFilePath(rctx.Str("path"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. 签名拿 presigned signed_url。
|
||||
signData, err := rctx.CallAPITyped("POST", appFileSignPath(appID), nil, map[string]interface{}{"path": remotePath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signedURL := common.GetString(signData, "signed_url")
|
||||
if signedURL == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "sign returned no signed_url")
|
||||
}
|
||||
|
||||
// 2. 直连 GET signed_url 落盘。
|
||||
out := strings.TrimSpace(rctx.Str("output"))
|
||||
if out == "" {
|
||||
out = path.Base(strings.TrimPrefix(remotePath, "/"))
|
||||
if out == "" || out == "." || out == "/" {
|
||||
out = "download"
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(rctx.Ctx(), http.MethodGet, signedURL, nil) //nolint:forbidigo // GET from a presigned object-storage URL bypasses the Lark gateway; raw HTTP required (not a Lark API call).
|
||||
if err != nil {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "build download request").WithCause(err)
|
||||
}
|
||||
resp, err := newFileTransferClient().Do(req) //nolint:forbidigo // see above: direct presigned-URL download, RuntimeContext.DoAPI does not apply.
|
||||
if err != nil {
|
||||
// dial/transport 失败是典型可重试场景。
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed").WithCause(err).WithRetryable()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
// 5xx 是上游瞬时故障,标 retryable;4xx(如签名过期)需重新签名而非盲重试,不标。
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed: HTTP %d", resp.StatusCode).WithRetryable()
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
resolved, perr := rctx.FileIO().ResolvePath(out)
|
||||
if perr != nil || resolved == "" {
|
||||
resolved = out
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"path": remotePath,
|
||||
"output": resolved,
|
||||
"size_bytes": saved.Size(),
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Downloaded %s → %s (%s)\n", remotePath, resolved, humanBytes(saved.Size()))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const fileSignURLForDownload = "/open-apis/spark/v1/apps/app_x/storage/file_sign"
|
||||
|
||||
// TestAppsFileDownload_RequiresAppIDAndPath 验证仅含空白的 --path 触发 --path typed 校验错误。
|
||||
func TestAppsFileDownload_RequiresAppIDAndPath(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileDownload,
|
||||
[]string{"+file-download", "--app-id", "app_x", "--path", " ", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--path" {
|
||||
t.Fatalf("Param = %q, want --path", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileDownload_DryRunSignsFirst 验证 dry-run 第一步是 POST file_sign。
|
||||
func TestAppsFileDownload_DryRunSignsFirst(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileDownload,
|
||||
[]string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Method != "POST" || env.API[0].URL != fileSignURLForDownload {
|
||||
t.Fatalf("dry-run = %s %s (want POST sign)", env.API[0].Method, env.API[0].URL)
|
||||
}
|
||||
}
|
||||
|
||||
// sign → 客户端 GET presigned signed_url → 落盘 --output。
|
||||
func TestAppsFileDownload_EndToEnd(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
io.WriteString(w, "PNGDATA")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: fileSignURLForDownload,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"signed_url": srv.URL}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsFileDownload,
|
||||
[]string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--output", "out.png", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, "out.png"))
|
||||
if err != nil {
|
||||
t.Fatalf("read output file: %v", err)
|
||||
}
|
||||
if string(b) != "PNGDATA" {
|
||||
t.Fatalf("downloaded content = %q, want PNGDATA", b)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"size_bytes": 7`) {
|
||||
t.Errorf("output json missing size_bytes:7\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 不传 --output → 默认远端 basename。
|
||||
func TestAppsFileDownload_DefaultsOutputToBasename(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "DATA")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: fileSignURLForDownload,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"signed_url": srv.URL}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsFileDownload,
|
||||
[]string{"+file-download", "--app-id", "app_x", "--path", "/1858537546760216.png", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "1858537546760216.png")); err != nil {
|
||||
t.Fatalf("default output basename not written: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsFileGet gets one file's metadata by exact remote path(动词对齐 +file-list)。
|
||||
//
|
||||
// GET /apps/{app_id}/storage/file?path=<path>。file 仅按 path 精确寻址,无按名寻址。
|
||||
// pretty 渲染 key/value:file_name / path / size(含 bytes) / type / uploaded_by(只 name) / uploaded_at /
|
||||
// download_url(条件出现)。server created_at/created_by → uploaded_at/uploaded_by。
|
||||
var AppsFileGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+file-get",
|
||||
Description: "Get a single file's metadata by path",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +file-get --app-id <app_id> --path /1858537546760216.png",
|
||||
"Tip: extract a single field with --jq, e.g. -q '.size_bytes' or -q '.download_url'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "path", Desc: "remote file path", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := requireFilePath(rctx.Str("path"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appFileGetPath(appID)).
|
||||
Desc("Get Miaoda app file metadata").
|
||||
Params(buildFileGetParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appFileGetPath(appID), buildFileGetParams(rctx), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info := projectFileInfo(data)
|
||||
rctx.OutFormat(info, nil, func(w io.Writer) {
|
||||
renderFileGetPretty(w, info)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildFileGetParams 组装 file_get 查询参数:按 path 精确寻址单文件。
|
||||
func buildFileGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
path, _ := requireFilePath(rctx.Str("path"))
|
||||
return map[string]interface{}{"path": path}
|
||||
}
|
||||
|
||||
// renderFileGetPretty 输出对齐 key/value;uploaded_by 只展示 name(id 仅 json 保留)。
|
||||
func renderFileGetPretty(w io.Writer, info fileInfo) {
|
||||
pairs := [][2]string{
|
||||
{"file_name", dashIfEmpty(info.FileName)},
|
||||
{"path", info.Path},
|
||||
{"size", fileSizeDetail(info.SizeBytes)},
|
||||
{"type", dashIfEmpty(info.Type)},
|
||||
}
|
||||
if info.UploadedBy != nil {
|
||||
pairs = append(pairs, [2]string{"uploaded_by", info.UploadedBy.Name})
|
||||
}
|
||||
pairs = append(pairs, [2]string{"uploaded_at", dashIfEmpty(info.UploadedAt)})
|
||||
if info.DownloadURL != "" {
|
||||
pairs = append(pairs, [2]string{"download_url", info.DownloadURL})
|
||||
}
|
||||
renderKeyValuePairs(w, pairs)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const fileGetURL = "/open-apis/spark/v1/apps/app_x/storage/file"
|
||||
|
||||
// TestAppsFileGet_RequiresAppIDAndPath 验证空白 --app-id 与空白 --path 分别触发对应的 typed 校验错误。
|
||||
func TestAppsFileGet_RequiresAppIDAndPath(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileGet,
|
||||
[]string{"+file-get", "--app-id", " ", "--path", "/x.png", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--app-id" {
|
||||
t.Fatalf("Param = %q, want --app-id", ve.Param)
|
||||
}
|
||||
factory2, stdout2, _ := newAppsExecuteFactory(t)
|
||||
err2 := runAppsShortcut(t, AppsFileGet,
|
||||
[]string{"+file-get", "--app-id", "app_x", "--path", " ", "--as", "user"}, factory2, stdout2)
|
||||
var ve2 *errs.ValidationError
|
||||
if !errors.As(err2, &ve2) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err2, err2)
|
||||
}
|
||||
if ve2.Param != "--path" {
|
||||
t.Fatalf("Param = %q, want --path", ve2.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileGet_DryRunSendsPathQuery 验证 dry-run 输出 GET file,path 作为 query 参数下发。
|
||||
func TestAppsFileGet_DryRunSendsPathQuery(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileGet,
|
||||
[]string{"+file-get", "--app-id", "app_x", "--path", "/x.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Method != "GET" || env.API[0].URL != fileGetURL || env.API[0].Params["path"] != "/x.png" {
|
||||
t.Fatalf("dry-run = %s %s params=%v", env.API[0].Method, env.API[0].URL, env.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileGet_SuccessAndPrettyKeyValue 验证 pretty key/value 展示 size 含 bytes、uploaded_by 只显示 name 且不泄漏 user id。
|
||||
func TestAppsFileGet_SuccessAndPrettyKeyValue(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: fileGetURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "logo.png", "path": "/1858537546760216.png",
|
||||
"size_bytes": 24580, "type": "image/png",
|
||||
"created_at": "2026-04-15T10:30:00Z",
|
||||
"created_by": `{"id":"7311","name":"alice"}`,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsFileGet,
|
||||
[]string{"+file-get", "--app-id", "app_x", "--path", "/1858537546760216.png", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// pretty key/value:size 含 bytes、uploaded_by 只展示 name。
|
||||
for _, want := range []string{"file_name:", "24 KB (24580 bytes)", "uploaded_by: alice", "uploaded_at: 2026-04-15T10:30:00Z"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("pretty missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// pretty 不该泄漏 user id。
|
||||
if strings.Contains(got, "7311") {
|
||||
t.Errorf("pretty should show name only, not id:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
|
||||
//
|
||||
// GET /apps/{app_id}/storage/file_list。过滤器:--name / --path / --type / --size-gt /
|
||||
// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size/--page-token。
|
||||
// file 域不分 dev/online,无 --env。
|
||||
//
|
||||
// pretty 渲染 5 列:file_name / path / size / type / uploaded_at;空结果打 "No files found."。
|
||||
// server 字段 created_at → 产品语义 uploaded_at。
|
||||
var AppsFileList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+file-list",
|
||||
Description: "List files in a Miaoda app's storage (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +file-list --app-id <app_id>",
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.items[].path'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "name", Desc: "filter by exact file name"},
|
||||
{Name: "path", Desc: "filter by exact remote path"},
|
||||
{Name: "type", Desc: "filter by MIME type"},
|
||||
{Name: "size-gt", Type: "int", Desc: "filter: size greater than (bytes)"},
|
||||
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
|
||||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"},
|
||||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。
|
||||
for _, f := range []string{"uploaded-since", "uploaded-until"} {
|
||||
if strings.TrimSpace(rctx.Str(f)) == "" {
|
||||
continue
|
||||
}
|
||||
n, err := normalizeTimestamp(rctx.Str(f))
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: %v", f, err).WithParam("--" + f)
|
||||
}
|
||||
_ = rctx.Cmd.Flags().Set(f, n)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appFileListPath(appID)).
|
||||
Desc("List Miaoda app files").
|
||||
Params(buildFileListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appFileListPath(appID), buildFileListParams(rctx), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 白名单投影:server created_at/created_by → uploaded_at/uploaded_by,替换原始 items[]。
|
||||
items := projectFileItems(data["items"])
|
||||
data["items"] = items
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderFileListPretty(w, items)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// projectFileItems 把服务端原始 items 逐项投影为白名单 fileInfo(created_*→uploaded_*)。
|
||||
func projectFileItems(raw interface{}) []fileInfo {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]fileInfo, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
out = append(out, projectFileInfo(m))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildFileListParams 组装 file_list 查询参数:page_size 及可选 name/path/type/size_gt/size_lt/uploaded_since/uploaded_until/page_token。
|
||||
func buildFileListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
}
|
||||
}
|
||||
addStr("name", "name")
|
||||
addStr("path", "path")
|
||||
addStr("type", "type")
|
||||
addStr("uploaded-since", "uploaded_since")
|
||||
addStr("uploaded-until", "uploaded_until")
|
||||
addStr("page-token", "page_token")
|
||||
if v := rctx.Int("size-gt"); v > 0 {
|
||||
params["size_gt"] = v
|
||||
}
|
||||
if v := rctx.Int("size-lt"); v > 0 {
|
||||
params["size_lt"] = v
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// renderFileListPretty 5 列对齐表:file_name / path / size / type / uploaded_at。
|
||||
func renderFileListPretty(w io.Writer, items []fileInfo) {
|
||||
if len(items) == 0 {
|
||||
io.WriteString(w, "No files found.\n")
|
||||
return
|
||||
}
|
||||
headers := []string{"file_name", "path", "size", "type", "uploaded_at"}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
rows = append(rows, []string{
|
||||
dashIfEmpty(it.FileName),
|
||||
it.Path,
|
||||
humanBytes(it.SizeBytes),
|
||||
dashIfEmpty(it.Type),
|
||||
dashIfEmpty(it.UploadedAt),
|
||||
})
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// 设计原则三:<timestamp> 四种格式 → 统一 RFC3339 UTC。
|
||||
func TestNormalizeTimestamp_AllFormats(t *testing.T) {
|
||||
// 空串透传
|
||||
if got, err := normalizeTimestamp(" "); err != nil || got != "" {
|
||||
t.Fatalf("empty → %q,%v want \"\",nil", got, err)
|
||||
}
|
||||
|
||||
// ISO 8601 带 TZ:Z 原样、显式偏移换算到 UTC
|
||||
mustEq := func(in, want string) {
|
||||
got, err := normalizeTimestamp(in)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("normalizeTimestamp(%q)=%q,%v want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
mustEq("2026-04-15T10:00:00Z", "2026-04-15T10:00:00Z")
|
||||
mustEq("2026-04-15T10:00:00+08:00", "2026-04-15T02:00:00Z") // +08:00 → UTC -8h
|
||||
|
||||
// date / local datetime:按本地时区解释再转 UTC(与 time.ParseInLocation 对齐)
|
||||
dExp, _ := time.ParseInLocation("2006-01-02", "2026-04-15", time.Local)
|
||||
mustEq("2026-04-15", dExp.UTC().Format(time.RFC3339))
|
||||
ldExp, _ := time.ParseInLocation("2006-01-02T15:04:05", "2026-04-15T10:00:00", time.Local)
|
||||
mustEq("2026-04-15T10:00:00", ldExp.UTC().Format(time.RFC3339))
|
||||
|
||||
// 相对:从现在往前推,结果应 ≈ now-dur(5s 容差)
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
dur time.Duration
|
||||
}{{"30s", 30 * time.Second}, {"5m", 5 * time.Minute}, {"2h", 2 * time.Hour}, {"3d", 72 * time.Hour}, {"1w", 7 * 24 * time.Hour}} {
|
||||
got, err := normalizeTimestamp(c.in)
|
||||
if err != nil {
|
||||
t.Errorf("normalizeTimestamp(%q) err=%v", c.in, err)
|
||||
continue
|
||||
}
|
||||
ts, perr := time.Parse(time.RFC3339, got)
|
||||
if perr != nil {
|
||||
t.Errorf("normalizeTimestamp(%q)=%q not RFC3339", c.in, got)
|
||||
continue
|
||||
}
|
||||
want := time.Now().Add(-c.dur)
|
||||
if diff := want.Sub(ts); diff > 5*time.Second || diff < -5*time.Second {
|
||||
t.Errorf("normalizeTimestamp(%q)=%q off by %v from now-%v", c.in, got, diff, c.dur)
|
||||
}
|
||||
}
|
||||
|
||||
// 非法格式 → error
|
||||
for _, bad := range []string{"notatime", "7x", "2026/04/15", "2026-13-99"} {
|
||||
if _, err := normalizeTimestamp(bad); err == nil {
|
||||
t.Errorf("normalizeTimestamp(%q) expected error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fileListURL = "/open-apis/spark/v1/apps/app_x/storage/file_list"
|
||||
|
||||
// TestAppsFileList_RequiresAppID 验证空白 --app-id 触发 --app-id typed 校验错误。
|
||||
func TestAppsFileList_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", " ", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--app-id" {
|
||||
t.Fatalf("Param = %q, want --app-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤器 + 分页全部进 query(size-gt/lt 走 int,uploaded_since/until 原样)。
|
||||
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x",
|
||||
"--name", "logo.png", "--path", "/x.png", "--type", "image/png",
|
||||
"--size-gt", "100", "--size-lt", "9000",
|
||||
"--uploaded-since", "2026-01-01", "--uploaded-until", "2026-02-01",
|
||||
"--page-size", "5", "--page-token", "cur-1",
|
||||
"--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != fileListURL {
|
||||
t.Fatalf("method/url = %s %s", a.Method, a.URL)
|
||||
}
|
||||
// 设计原则三:date 入参会被归一化为 RFC3339 UTC,期望值用 normalizeTimestamp 计算(避开本地时区脆弱断言)。
|
||||
sinceN, _ := normalizeTimestamp("2026-01-01")
|
||||
untilN, _ := normalizeTimestamp("2026-02-01")
|
||||
wantStr := map[string]string{
|
||||
"name": "logo.png", "path": "/x.png", "type": "image/png",
|
||||
"uploaded_since": sinceN, "uploaded_until": untilN, "page_token": "cur-1",
|
||||
}
|
||||
for k, v := range wantStr {
|
||||
if a.Params[k] != v {
|
||||
t.Errorf("params.%s = %v, want %v", k, a.Params[k], v)
|
||||
}
|
||||
}
|
||||
// 且确实归一化成了 UTC(以 Z 结尾),不是原样透传。
|
||||
if s, _ := a.Params["uploaded_since"].(string); !strings.HasSuffix(s, "Z") {
|
||||
t.Errorf("uploaded_since not normalized to RFC3339 UTC: %v", a.Params["uploaded_since"])
|
||||
}
|
||||
for _, k := range []string{"size_gt", "size_lt", "page_size"} {
|
||||
if _, ok := a.Params[k]; !ok {
|
||||
t.Errorf("params missing %s: %v", k, a.Params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 0 值过滤器不下发(size-gt/lt 缺省 0、空字符串过滤器)。
|
||||
func TestAppsFileList_DryRunOmitsEmptyFilters(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
for _, banned := range []string{"name", "path", "type", "size_gt", "size_lt", "uploaded_since", "uploaded_until", "page_token"} {
|
||||
if _, ok := env.API[0].Params[banned]; ok {
|
||||
t.Errorf("params should omit empty %s: %v", banned, env.API[0].Params)
|
||||
}
|
||||
}
|
||||
if _, ok := env.API[0].Params["page_size"]; !ok {
|
||||
t.Errorf("params should always carry page_size: %v", env.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
// created_at/created_by → uploaded_at/uploaded_by;created_by 是 JSON 字符串 → parse 成对象。
|
||||
func TestAppsFileList_SuccessProjectsCreatedToUploaded(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: fileListURL,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_name": "logo.png",
|
||||
"path": "/1858537546760216.png",
|
||||
"size_bytes": 24580,
|
||||
"type": "image/png",
|
||||
"created_at": "2026-04-15T10:30:00Z",
|
||||
"created_by": `{"id":"7311","name":"alice"}`,
|
||||
"download_url": "/spark/app/x/1858537546760216.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"uploaded_at": "2026-04-15T10:30:00Z"`, `"uploaded_by"`, `"name": "alice"`, `"id": "7311"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// created_* 不应再出现在输出。
|
||||
for _, banned := range []string{"created_at", "created_by"} {
|
||||
if strings.Contains(got, banned) {
|
||||
t.Errorf("stdout should not contain %q (renamed to uploaded_*):\n%s", banned, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileList_PrettyTableAndEmpty 验证 pretty 非空时渲染表头与人类可读 size,空结果时输出 "No files found."。
|
||||
func TestAppsFileList_PrettyTableAndEmpty(t *testing.T) {
|
||||
// 非空:5 列表头。
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: fileListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{
|
||||
"file_name": "logo.png", "path": "/x.png", "size_bytes": 24576, "type": "image/png",
|
||||
"created_at": "2026-04-15T10:30:00Z",
|
||||
}},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "file_name") || !strings.Contains(got, "uploaded_at") || !strings.Contains(got, "24 KB") {
|
||||
t.Fatalf("pretty table malformed:\n%s", got)
|
||||
}
|
||||
|
||||
// 空:No files found.
|
||||
factory2, stdout2, reg2 := newAppsExecuteFactory(t)
|
||||
reg2.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: fileListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout2.String(), "No files found.") {
|
||||
t.Fatalf("empty pretty should say 'No files found.', got: %s", stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFileUser_Cases 验证 parseFileUser:合法 JSON 解析成对象,空串/非法/全空字段均返回 nil。
|
||||
func TestParseFileUser_Cases(t *testing.T) {
|
||||
if u := parseFileUser(`{"id":"1","name":"a"}`); u == nil || u.ID != "1" || u.Name != "a" {
|
||||
t.Fatalf("valid parse failed: %#v", u)
|
||||
}
|
||||
if u := parseFileUser(""); u != nil {
|
||||
t.Errorf("empty → nil, got %#v", u)
|
||||
}
|
||||
if u := parseFileUser("not json"); u != nil {
|
||||
t.Errorf("invalid → nil, got %#v", u)
|
||||
}
|
||||
if u := parseFileUser(`{"id":"","name":""}`); u != nil {
|
||||
t.Errorf("all-empty → nil, got %#v", u)
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsFileQuotaGet reports an app's file-storage usage(动词对齐 +db-quota-get)。
|
||||
//
|
||||
// GET /apps/{app_id}/storage/file_quota。storage_quota_bytes / usage_percent 在配额未对接(=0)时
|
||||
// 不输出(json 删字段、pretty 只打已用量)。
|
||||
var AppsFileQuotaGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+file-quota-get",
|
||||
Description: "Get an app's file-storage usage",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +file-quota-get --app-id <app_id>",
|
||||
"Tip: get just the usage percent with -q '.usage_percent'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appFileQuotaPath(appID)).
|
||||
Desc("Get Miaoda app file-storage usage")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appFileQuotaPath(appID), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := projectFileQuota(data)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderFileQuotaPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// projectFileQuota 白名单投影 file quota 字段:只保留 agent 需要的 storage_used_bytes / files,
|
||||
// 配额已对接时再加 storage_quota_bytes / usage_percent。不透传后端其它字段,避免无用字段消耗上下文。
|
||||
func projectFileQuota(data map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{"storage_used_bytes": data["storage_used_bytes"]}
|
||||
if v, ok := data["files"]; ok {
|
||||
out["files"] = v
|
||||
}
|
||||
// 配额未对接(storage_quota_bytes=0/缺失)时不输出 quota / usage_percent,避免误导。
|
||||
if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 {
|
||||
out["storage_quota_bytes"] = data["storage_quota_bytes"]
|
||||
if v, ok := data["usage_percent"]; ok {
|
||||
out["usage_percent"] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderFileQuotaPretty 打 Storage(已用 / 配额 (百分比))与 Files 行(标签对齐 miaoda-cli)。
|
||||
func renderFileQuotaPretty(w io.Writer, data map[string]interface{}) {
|
||||
used := humanBytes(data["storage_used_bytes"])
|
||||
usage := used
|
||||
if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 {
|
||||
pct := ""
|
||||
if p, ok := numericAsFloat(data["usage_percent"]); ok {
|
||||
pct = fmt.Sprintf(" (%.1f%%)", p)
|
||||
}
|
||||
usage = fmt.Sprintf("%s / %s%s", used, humanBytes(data["storage_quota_bytes"]), pct)
|
||||
}
|
||||
pairs := [][2]string{{"Storage", usage}}
|
||||
if f, ok := numericAsFloat(data["files"]); ok {
|
||||
pairs = append(pairs, [2]string{"Files", fmt.Sprintf("%d", int64(f))})
|
||||
}
|
||||
renderKeyValuePairs(w, pairs)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const fileQuotaURL = "/open-apis/spark/v1/apps/app_x/storage/file_quota"
|
||||
|
||||
// TestAppsFileQuotaGet_QuotaConnectedShowsAllFields 验证配额已对接时输出 storage_quota_bytes/usage_percent/files 全字段。
|
||||
func TestAppsFileQuotaGet_QuotaConnectedShowsAllFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: fileQuotaURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"storage_used_bytes": 157286400,
|
||||
"storage_quota_bytes": 1073741824,
|
||||
"usage_percent": 14.6,
|
||||
"files": 42,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsFileQuotaGet,
|
||||
[]string{"+file-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"storage_quota_bytes"`, `"usage_percent"`, `"files"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("quota json missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 配额未对接(=0):storage_quota_bytes / usage_percent 不输出。
|
||||
func TestAppsFileQuotaGet_UnconnectedOmitsQuotaFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: fileQuotaURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"storage_used_bytes": 157286400,
|
||||
"storage_quota_bytes": 0,
|
||||
"usage_percent": 0,
|
||||
"files": 42,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsFileQuotaGet,
|
||||
[]string{"+file-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, banned := range []string{"storage_quota_bytes", "usage_percent"} {
|
||||
if strings.Contains(got, banned) {
|
||||
t.Errorf("unconnected quota should omit %q:\n%s", banned, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, `"storage_used_bytes"`) || !strings.Contains(got, `"files"`) {
|
||||
t.Errorf("should still show used/files:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFileQuota_OmitsZeroQuotaAndDropsUnknownFields 验证 projectFileQuota 白名单投影:
|
||||
// quota=0 时不输出 storage_quota_bytes/usage_percent,非零时保留;后端额外字段不透传。
|
||||
func TestProjectFileQuota_OmitsZeroQuotaAndDropsUnknownFields(t *testing.T) {
|
||||
out := projectFileQuota(map[string]interface{}{
|
||||
"storage_used_bytes": 100, "storage_quota_bytes": float64(0), "usage_percent": float64(0),
|
||||
"files": 3, "tenant_key": "leak", "request_id": "rid",
|
||||
})
|
||||
if _, ok := out["storage_quota_bytes"]; ok {
|
||||
t.Errorf("zero quota should be omitted: %v", out)
|
||||
}
|
||||
if _, ok := out["usage_percent"]; ok {
|
||||
t.Errorf("usage_percent should be omitted when quota=0: %v", out)
|
||||
}
|
||||
if out["storage_used_bytes"] != 100 || out["files"] != 3 {
|
||||
t.Errorf("whitelisted fields should be kept: %v", out)
|
||||
}
|
||||
// 白名单外的字段必须被丢弃,避免无用字段消耗 agent 上下文。
|
||||
for _, leaked := range []string{"tenant_key", "request_id"} {
|
||||
if _, ok := out[leaked]; ok {
|
||||
t.Errorf("non-whitelisted field %q must be dropped: %v", leaked, out)
|
||||
}
|
||||
}
|
||||
|
||||
out2 := projectFileQuota(map[string]interface{}{"storage_used_bytes": 100, "storage_quota_bytes": float64(1024), "usage_percent": float64(9.8), "files": 3})
|
||||
if _, ok := out2["storage_quota_bytes"]; !ok {
|
||||
t.Errorf("non-zero quota should be kept: %v", out2)
|
||||
}
|
||||
if _, ok := out2["usage_percent"]; !ok {
|
||||
t.Errorf("usage_percent should be kept when quota>0: %v", out2)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// fileSignMaxExpiresSeconds 是签名链接最长有效期(30 天)。超出 → 校验失败。
|
||||
const fileSignMaxExpiresSeconds = 30 * 24 * 60 * 60
|
||||
|
||||
// AppsFileSign generates a temporary signed download URL for a file。
|
||||
//
|
||||
// POST /apps/{app_id}/storage/file_sign,body {path, expires_in}。
|
||||
// pretty 模式只打 signed_url(便于直接管道 / curl);json 返 {file_name,path,signed_url,expires_at}。
|
||||
var AppsFileSign = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+file-sign",
|
||||
Description: "Generate a temporary signed download URL for a file",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +file-sign --app-id <app_id> --path /1858537546760216.png",
|
||||
"Tip: curl the signed_url directly to download.",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "path", Desc: "remote file path", Required: true},
|
||||
{Name: "expires-in", Type: "int", Default: "86400", Desc: "link validity in seconds (max 2592000 = 30d)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireFilePath(rctx.Str("path")); err != nil {
|
||||
return err
|
||||
}
|
||||
if rctx.Int("expires-in") > fileSignMaxExpiresSeconds {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--expires-in exceeds the maximum of %d seconds (30d)", fileSignMaxExpiresSeconds).WithParam("--expires-in")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appFileSignPath(appID)).
|
||||
Desc("Sign a temporary download URL").
|
||||
Body(buildFileSignBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", appFileSignPath(appID), nil, buildFileSignBody(rctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, common.GetString(data, "signed_url"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildFileSignBody 组装 file_sign 请求体:path 及可选 expires_in(秒)。
|
||||
func buildFileSignBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
path, _ := requireFilePath(rctx.Str("path"))
|
||||
body := map[string]interface{}{"path": path}
|
||||
if v := rctx.Int("expires-in"); v > 0 {
|
||||
body["expires_in"] = v
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const fileSignURL = "/open-apis/spark/v1/apps/app_x/storage/file_sign"
|
||||
|
||||
// TestAppsFileSign_DryRunBody 验证 dry-run 输出 POST file_sign,body 携带 path 与 expires_in。
|
||||
func TestAppsFileSign_DryRunBody(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileSign,
|
||||
[]string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "3600", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != fileSignURL || a.Body["path"] != "/x.png" {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
if ei, _ := a.Body["expires_in"].(float64); int(ei) != 3600 {
|
||||
t.Fatalf("body.expires_in = %v, want 3600", a.Body["expires_in"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileSign_RejectsDurationOverMax 验证 --expires-in 超过上限时触发 --expires-in typed 校验错误。
|
||||
func TestAppsFileSign_RejectsDurationOverMax(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileSign,
|
||||
[]string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "9999999", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--expires-in" {
|
||||
t.Fatalf("Param = %q, want --expires-in", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileSign_PrettyPrintsSignedURL 验证 pretty 只输出 signed_url 本身。
|
||||
func TestAppsFileSign_PrettyPrintsSignedURL(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: fileSignURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "x.png", "path": "/x.png",
|
||||
"signed_url": "https://tos.example/x.png?sig=abc", "expires_at": "2026-04-16T10:30:00Z",
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsFileSign,
|
||||
[]string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := strings.TrimSpace(stdout.String())
|
||||
if got != "https://tos.example/x.png?sig=abc" {
|
||||
t.Fatalf("pretty should print only signed_url, got: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// fileUploadMaxBytes 是单文件上传上限(100 MB,对齐 miaoda)。
|
||||
const fileUploadMaxBytes = 100 * 1024 * 1024
|
||||
|
||||
// AppsFileUpload uploads a local file to an app's storage(三步直传)。
|
||||
//
|
||||
// 1. POST /apps/{app_id}/storage/file_pre_upload {file_name,file_size,content_type} → {upload_url,upload_id}
|
||||
// 2. 客户端 PUT 文件字节到 presigned upload_url,取响应 ETag
|
||||
// 3. POST /apps/{app_id}/storage/file_upload_callback {upload_id,etag} → 文件元数据
|
||||
// file_name 取本地 basename;path 由平台生成 16 位 ID(不可指定)。仅收 --file。
|
||||
var AppsFileUpload = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+file-upload",
|
||||
Description: "Upload a local file to an app's storage",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +file-upload --app-id <app_id> --file ./logo.png",
|
||||
"Example: lark-cli apps +file-upload --app-id <app_id> --file ./report.pdf -q '.path' # print the platform-generated file path",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "file", Desc: "local file to upload (file_name = basename)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
f := strings.TrimSpace(rctx.Str("file"))
|
||||
if f == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
|
||||
}
|
||||
st, err := rctx.FileIO().Stat(f)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
}
|
||||
if st.IsDir() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
|
||||
}
|
||||
if st.Size() > fileUploadMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appFilePreUploadPath(appID)).
|
||||
Desc("Pre-upload → client PUT bytes → callback (3-step)").
|
||||
Body(map[string]interface{}{"file_name": filepath.Base(strings.TrimSpace(rctx.Str("file")))})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
localPath := strings.TrimSpace(rctx.Str("file"))
|
||||
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
}
|
||||
fileName := filepath.Base(localPath)
|
||||
contentType := mimeByExt(fileName)
|
||||
|
||||
// 1. pre-upload
|
||||
pre, err := rctx.CallAPITyped("POST", appFilePreUploadPath(appID), nil, map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"file_size": len(content),
|
||||
"content_type": contentType,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uploadURL := common.GetString(pre, "upload_url")
|
||||
uploadID := common.GetString(pre, "upload_id")
|
||||
if uploadURL == "" || uploadID == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "pre-upload returned no upload_url / upload_id")
|
||||
}
|
||||
|
||||
// 2. PUT 文件字节到 presigned URL,取 ETag(带 Content-Disposition 透传原始文件名)
|
||||
etag, err := putFileBytes(rctx.Ctx(), uploadURL, content, contentType, fileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. callback
|
||||
result, err := rctx.CallAPITyped("POST", appFileUploadCallbackPath(appID), nil, map[string]interface{}{
|
||||
"upload_id": uploadID,
|
||||
"etag": etag,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info := projectFileInfo(result)
|
||||
rctx.OutFormat(info, nil, func(w io.Writer) {
|
||||
renderFileUploadPretty(w, fileName, info)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// putFileBytes 直连 PUT 文件字节到 presigned URL,返回响应的 ETag。
|
||||
//
|
||||
// Content-Disposition 透传原始文件名:TOS 把它存成对象 metadata,callback 阶段后端
|
||||
// HeadObject 读回解析出 filename 写入 DB 的 display name。不传则后端兜底用 storage key
|
||||
// (平台 16 位 ID)当文件名 —— 即「上传后文件名变成 ID」的根因。
|
||||
//
|
||||
//nolint:forbidigo // direct PUT to a presigned object-storage URL bypasses the Lark gateway — raw HTTP is required (no Lark auth/gateway); RuntimeContext.DoAPI cannot target a presigned URL.
|
||||
func putFileBytes(ctx context.Context, url string, content []byte, contentType, fileName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "build upload request").WithCause(err)
|
||||
}
|
||||
req.ContentLength = int64(len(content))
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
req.Header.Set("Content-Disposition", "attachment; filename=\""+sanitizeUploadFileName(fileName)+"\"")
|
||||
resp, err := newFileTransferClient().Do(req)
|
||||
if err != nil {
|
||||
// dial/transport 失败是典型可重试场景。
|
||||
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed").WithCause(err).WithRetryable()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode >= 400 {
|
||||
// 5xx 是上游瞬时故障,标 retryable;4xx(如签名过期)需重新签名而非盲重试,不标。
|
||||
if resp.StatusCode >= 500 {
|
||||
return "", errs.NewNetworkError(errs.SubtypeNetworkServer, "upload failed: HTTP %d", resp.StatusCode).WithRetryable()
|
||||
}
|
||||
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
// sanitizeUploadFileName 对齐 miaoda:先去掉 TOS 非法字符 [:"\/*?<>|,;],再 encodeURIComponent
|
||||
// (UTF-8 百分号编码,兼容中文等非 ASCII,且让 Content-Disposition header 合法),空则兜底 download_file。
|
||||
func sanitizeUploadFileName(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch r {
|
||||
case ':', '"', '\\', '/', '*', '?', '<', '>', '|', ',', ';':
|
||||
continue
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
enc := encodeURIComponent(b.String())
|
||||
if enc == "" {
|
||||
return "download_file"
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
// encodeURIComponent 复刻 JS encodeURIComponent:除 A-Za-z0-9-_.!~*'() 外按 UTF-8 字节 %XX 编码。
|
||||
func encodeURIComponent(s string) string {
|
||||
const keep = "-_.!~*'()"
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || strings.IndexByte(keep, c) >= 0 {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("%%%02X", c))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mimeByExt 按扩展名推断 Content-Type,未知回退 application/octet-stream。
|
||||
func mimeByExt(name string) string {
|
||||
if t := mime.TypeByExtension(filepath.Ext(name)); t != "" {
|
||||
return t
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// renderFileUploadPretty 打 ✓ Uploaded <local> → <path> + size / download_url。
|
||||
func renderFileUploadPretty(w io.Writer, localName string, info fileInfo) {
|
||||
fmt.Fprintf(w, "✓ Uploaded %s → %s\n", localName, info.Path)
|
||||
fmt.Fprintf(w, "size: %s\n", fileSizeDetail(info.SizeBytes))
|
||||
if info.DownloadURL != "" {
|
||||
fmt.Fprintf(w, "download_url: %s\n", info.DownloadURL)
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestAppsFileUpload_RequiresAppIDAndFile 验证仅含空白的 --file 经 Validate 去空后触发 --file typed 校验错误。
|
||||
func TestAppsFileUpload_RequiresAppIDAndFile(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// --file is a cobra-required flag; pass whitespace so cobra's required check
|
||||
// passes and our Validate (which trims) rejects it with a typed error.
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", " ", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("Param = %q, want --file", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_RejectsDirectory 验证 --file 指向目录时触发 --file typed 校验错误。
|
||||
func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "sub", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("Param = %q, want --file", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_upload,body.file_name 取文件 basename。
|
||||
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
// Validate 会 Stat --file(在 DryRun 之前),故 dry-run 也需要真实存在的文件。
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload" {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Body["file_name"] != "logo.png" {
|
||||
t.Fatalf("dry-run body.file_name = %v, want logo.png (basename)", a.Body["file_name"])
|
||||
}
|
||||
}
|
||||
|
||||
// 三步直传:pre-upload → 客户端 PUT 字节 → callback。
|
||||
func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
var putBody []byte
|
||||
var putContentType, putCD string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
putBody, _ = io.ReadAll(r.Body)
|
||||
putContentType = r.Header.Get("Content-Type")
|
||||
putCD = r.Header.Get("Content-Disposition")
|
||||
w.Header().Set("ETag", `"etag-123"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("PNGBYTES"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "logo.png", "path": "/1858537546760216.png", "size_bytes": 8, "type": "image/png",
|
||||
"download_url": "/spark/app/x/1858537546760216.png",
|
||||
}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if string(putBody) != "PNGBYTES" {
|
||||
t.Fatalf("PUT body = %q, want file bytes", putBody)
|
||||
}
|
||||
if putContentType != "image/png" {
|
||||
t.Errorf("PUT Content-Type = %q, want image/png", putContentType)
|
||||
}
|
||||
// 原始文件名必须经 Content-Disposition 透传给 TOS(否则后端用 storage key 当文件名)。
|
||||
if putCD != `attachment; filename="logo.png"` {
|
||||
t.Errorf("PUT Content-Disposition = %q, want attachment; filename=\"logo.png\"", putCD)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"path": "/1858537546760216.png"`) {
|
||||
t.Errorf("output missing uploaded path:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName:空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
|
||||
func TestSanitizeUploadFileName_Cases(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"logo.png", "logo.png"},
|
||||
{"a b.png", "a%20b.png"}, // 空格 → %20(encodeURIComponent)
|
||||
{`a:b/c*d?.png`, "abcd.png"}, // 去掉 TOS 非法字符
|
||||
{"///", "download_file"}, // 全非法 → 兜底
|
||||
{"中.txt", "%E4%B8%AD.txt"}, // 非 ASCII → UTF-8 百分号编码
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := sanitizeUploadFileName(c.in); got != c.want {
|
||||
t.Errorf("sanitizeUploadFileName(%q)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMimeByExt_Cases 验证 mimeByExt:按扩展名识别 image/png,未知扩展名兜底 application/octet-stream。
|
||||
func TestMimeByExt_Cases(t *testing.T) {
|
||||
if got := mimeByExt("a.png"); !strings.HasPrefix(got, "image/png") {
|
||||
t.Errorf("mimeByExt(a.png)=%q want image/png", got)
|
||||
}
|
||||
if got := mimeByExt("data.unknownext"); got != "application/octet-stream" {
|
||||
t.Errorf("mimeByExt(unknown)=%q want application/octet-stream", got)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -35,11 +35,11 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
path := strings.TrimSpace(rctx.Str("path"))
|
||||
if path == "" {
|
||||
return output.ErrValidation("--path is required")
|
||||
return appsValidationParamError("--path", "--path is required")
|
||||
}
|
||||
// Block well-known credential files in the publish payload unless the
|
||||
// caller explicitly opts in. Lives in Validate (not DryRun) so that
|
||||
@@ -150,9 +150,9 @@ func sensitiveCandidatesError(hits []string) error {
|
||||
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
|
||||
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
|
||||
}
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("--path contains %d credential file(s) that should not be published: %s", len(hits), sample),
|
||||
"remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
|
||||
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
|
||||
}
|
||||
|
||||
// maxHTMLPublishTarballBytes 是 client 端 tar.gz 包体上限,对齐 OAPI 设计 20MB 约束。
|
||||
@@ -178,15 +178,14 @@ func ensureIndexHTML(candidates []htmlPublishCandidate) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return output.ErrWithHint(output.ExitAPI, "validation",
|
||||
"--path 中缺少 index.html",
|
||||
"妙搭以 index.html 作为应用入口;目录形态把首页放在根目录命名 index.html,单文件形态把文件命名为 index.html")
|
||||
return appsFailedPreconditionParamError("--path", "--path is missing index.html").
|
||||
WithHint("Miaoda uses index.html as the app entrypoint; for a directory put index.html at the root, or pass a single file named index.html")
|
||||
}
|
||||
|
||||
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "io", "scan --path %s: %v", spec.Path, err)
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureIndexHTML(candidates); err != nil {
|
||||
return nil, err
|
||||
@@ -196,24 +195,24 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPubli
|
||||
rawTotal += c.Size
|
||||
}
|
||||
if rawTotal > maxHTMLPublishRawBytes {
|
||||
return nil, output.ErrWithHint(output.ExitAPI, "validation",
|
||||
fmt.Sprintf("--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes),
|
||||
"在 tar+gzip 进入内存前拦截,避免 OOM;精简 --path 内容或选择更小的子目录")
|
||||
return nil, appsValidationParamError("--path",
|
||||
"--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes).
|
||||
WithHint("reduce --path contents or choose a smaller subdirectory before packaging")
|
||||
}
|
||||
tarball, err := buildHTMLPublishTarball(fio, candidates)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "io", "pack: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tarball.Size > maxHTMLPublishTarballBytes {
|
||||
return nil, output.ErrWithHint(output.ExitAPI, "validation",
|
||||
fmt.Sprintf("packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes),
|
||||
"请精简 --path 目录(去掉无关大文件 / 压缩资源)后重试;本期接口上限 20MB")
|
||||
return nil, appsValidationParamError("--path",
|
||||
"packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes).
|
||||
WithHint("reduce --path contents, remove unrelated large files, then retry")
|
||||
}
|
||||
|
||||
resp, err := client.HTMLPublish(ctx, spec.AppID, tarball)
|
||||
resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{}
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type fakeAppsHTMLPublishClient struct {
|
||||
@@ -105,17 +103,11 @@ func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing index.html")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "index.html") {
|
||||
t.Fatalf("message missing 'index.html': %v", problem.Message)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "index.html") {
|
||||
t.Fatalf("message missing 'index.html': %v", exitErr.Detail.Message)
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
@@ -153,10 +145,7 @@ func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("expected ExitError type=validation, got %v", err)
|
||||
}
|
||||
requireAppsValidationProblem(t, err)
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when index.html missing")
|
||||
}
|
||||
@@ -199,17 +188,11 @@ func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected oversize error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "exceeds") {
|
||||
t.Fatalf("message missing 'exceeds': %v", problem.Message)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "exceeds") {
|
||||
t.Fatalf("message missing 'exceeds': %v", exitErr.Detail.Message)
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
@@ -337,18 +320,12 @@ func TestAppsHTMLPublish_SensitiveBlocksValidate(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("dry-run with sensitive file should fail")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, ".env") {
|
||||
t.Fatalf("error message should list the offending file, got %q", problem.Message)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, ".env") {
|
||||
t.Fatalf("error message should list the offending file, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--allow-sensitive") {
|
||||
t.Fatalf("error hint should mention --allow-sensitive escape hatch, got %q", exitErr.Detail.Hint)
|
||||
if !strings.Contains(problem.Hint, "--allow-sensitive") {
|
||||
t.Fatalf("error hint should mention --allow-sensitive escape hatch, got %q", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,15 +415,9 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialParentDir(t *testing
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection when --path is %s/ (would leak %s), got success", tc.parent, tc.fileName)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, tc.wantSubstr) {
|
||||
t.Fatalf("error message should name the leaked file, got %q", exitErr.Detail.Message)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, tc.wantSubstr) {
|
||||
t.Fatalf("error message should name the leaked file, got %q", problem.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -480,15 +451,9 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself(t *testin
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection when --path points directly at .aws/credentials, got success")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "credentials") {
|
||||
t.Fatalf("error message should name the leaked file, got %q", exitErr.Detail.Message)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "credentials") {
|
||||
t.Fatalf("error message should name the leaked file, got %q", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,11 +463,7 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself(t *testin
|
||||
func TestSensitiveCandidatesError_Truncation(t *testing.T) {
|
||||
hits := []string{"a.env", "b.env", "c.env", "d.env", "e.env", "f.env", "g.env"}
|
||||
err := sensitiveCandidatesError(hits)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
msg := exitErr.Detail.Message
|
||||
msg := requireAppsValidationProblem(t, err).Message
|
||||
if !strings.Contains(msg, "7 credential file(s)") {
|
||||
t.Fatalf("message should report the full count, got %q", msg)
|
||||
}
|
||||
@@ -534,15 +495,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected raw-size cap to fire")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "raw") || !strings.Contains(exitErr.Detail.Message, "bytes") {
|
||||
t.Fatalf("expected message to explain raw-byte cap, got %q", exitErr.Detail.Message)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
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")
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -72,14 +72,14 @@ var AppsInit = common.Shortcut{
|
||||
// exit-1 (root.go handleRootError case 4), bypassing the structured
|
||||
// envelope. The spec and the E2E assert exit-2 + a structured
|
||||
// {"ok":false,"error":{...}} envelope for missing --app-id, so the empty
|
||||
// check lives in Validate (output.ErrValidation -> ExitValidation=2).
|
||||
// check lives in Validate (typed validation error -> exit 2).
|
||||
{Name: "app-id", Desc: "Miaoda app ID"},
|
||||
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
|
||||
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -152,14 +152,14 @@ func resolveTargetPath(rctx *common.RuntimeContext, appID string) (string, error
|
||||
// path is a log-injection vector); charcheck additionally rejects dangerous
|
||||
// Unicode (bidi overrides, zero-width) that IsControl does not.
|
||||
if strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return "", output.ErrValidation("--dir must not contain control characters")
|
||||
return "", appsValidationParamError("--dir", "--dir must not contain control characters")
|
||||
}
|
||||
if err := charcheck.RejectControlChars(raw, "--dir"); err != nil {
|
||||
return "", output.ErrValidation("%v", err)
|
||||
return "", appsValidationParamError("--dir", "%v", err).WithCause(err)
|
||||
}
|
||||
abs, err := filepath.Abs(raw) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); raw is control-char-validated above, and FileIO.ResolvePath cannot resolve a clone target (it rejects absolute paths).
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("--dir cannot be resolved: %v", err)
|
||||
return "", appsValidationParamError("--dir", "--dir cannot be resolved: %v", err)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
@@ -173,20 +173,20 @@ func ensureEmptyDir(dir string) error {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return output.ErrValidation("--dir cannot be read: %v", err)
|
||||
return appsValidationParamError("--dir", "--dir cannot be read: %v", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return output.ErrValidation("--dir must not be a symlink: %q", dir)
|
||||
return appsValidationParamError("--dir", "--dir must not be a symlink: %q", dir)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--dir exists and is not a directory: %q", dir)
|
||||
return appsValidationParamError("--dir", "--dir exists and is not a directory: %q", dir)
|
||||
}
|
||||
entries, err := os.ReadDir(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and FileIO has no ReadDir.
|
||||
if err != nil {
|
||||
return output.ErrValidation("--dir cannot be read: %v", err)
|
||||
return appsValidationParamError("--dir", "--dir cannot be read: %v", err)
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
return output.ErrValidation("target directory %q already exists and is not empty", dir)
|
||||
return appsValidationParamError("--dir", "target directory %q already exists and is not empty", dir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -209,11 +209,11 @@ func ensureMetaAppID(dir, appID string) error {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "read %s failed: %v", metaRelPath, err)
|
||||
return appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "parse %s failed: %v", metaRelPath, err)
|
||||
return appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
if cur, _ := m["app_id"].(string); strings.TrimSpace(cur) != "" {
|
||||
return nil
|
||||
@@ -224,10 +224,10 @@ func ensureMetaAppID(dir, appID string) error {
|
||||
m["app_id"] = appID
|
||||
out, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "marshal %s failed: %v", metaRelPath, err)
|
||||
return appsFileIOError(err, "marshal %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Save rejects absolute paths.
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "write %s failed: %v", metaRelPath, err)
|
||||
return appsFileIOError(err, "write %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func hasSteeringSkills(dir string) bool {
|
||||
func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
|
||||
stdout, stderr, err := initRunner.Run(ctx, dir, "git", "ls-files")
|
||||
if err != nil {
|
||||
return false, output.Errorf(output.ExitAPI, "git_ls_files", "git ls-files failed: %s", gitErr(stderr, err))
|
||||
return false, appsExternalToolError(err, "git ls-files failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
|
||||
f := strings.TrimSpace(line)
|
||||
@@ -274,19 +274,19 @@ func runScaffold(ctx context.Context, dir, appID, template string) (string, erro
|
||||
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
|
||||
// appear, extend isEmptyRepo's allow-list accordingly.
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_app_init", "npx app init failed: %s", gitErr(stderr, err))
|
||||
return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
return scaffoldKindInit, nil
|
||||
}
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_app_sync", "npx app sync failed: %s", gitErr(stderr, err))
|
||||
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
if err := ensureMetaAppID(dir, appID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasSteeringSkills(dir) {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_skills_sync", "npx skills sync failed: %s", gitErr(stderr, err))
|
||||
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
}
|
||||
return scaffoldKindUpgrade, nil
|
||||
@@ -303,13 +303,13 @@ func parseRepoURLFromEnvelope(stdout string) (string, error) {
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "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 "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init reported failure")
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init returned no repository_url")
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
|
||||
}
|
||||
return env.Data.RepositoryURL, nil
|
||||
}
|
||||
@@ -324,13 +324,13 @@ func parseEnvFileFromEnvelope(stdout string) (string, error) {
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "could not parse +env-pull output as JSON: %v", err)
|
||||
return "", appsSubprocessEnvelopeError("could not parse +env-pull output as JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull reported failure")
|
||||
return "", appsSubprocessEnvelopeError("+env-pull reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.EnvFile) == "" {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull returned no env_file")
|
||||
return "", appsSubprocessEnvelopeError("+env-pull returned no env_file")
|
||||
}
|
||||
return env.Data.EnvFile, nil
|
||||
}
|
||||
@@ -364,7 +364,9 @@ func validateRepoURLScheme(repoURL string) error {
|
||||
if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") {
|
||||
return nil
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "validation",
|
||||
// The URL comes from the +git-credential-init subprocess response, not user
|
||||
// input, so a non-http(s) scheme is a broken upstream contract.
|
||||
return appsSubprocessEnvelopeError(
|
||||
"repository_url from +git-credential-init must be http(s); refusing %q", redactURLCredentials(repoURL))
|
||||
}
|
||||
|
||||
@@ -415,12 +417,12 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "dependency",
|
||||
"git executable not found on PATH", "install git and ensure it is on your PATH")
|
||||
return appsFailedPreconditionError("git executable not found on PATH").
|
||||
WithHint("install git and ensure it is on your PATH")
|
||||
}
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "dependency",
|
||||
"npx executable not found on PATH", "install Node.js (which provides npx) and ensure it is on your PATH")
|
||||
return appsFailedPreconditionError("npx executable not found on PATH").
|
||||
WithHint("install Node.js (which provides npx) and ensure it is on your PATH")
|
||||
}
|
||||
|
||||
if err := ensureEmptyDir(dir); err != nil {
|
||||
@@ -438,11 +440,11 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
|
||||
initLogf(rctx, "Cloning into %s...", dir)
|
||||
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_clone", "git clone failed: %s", gitErr(stderr, err))
|
||||
return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
initLogf(rctx, "Checking out %s...", defaultInitBranch)
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "git", "checkout", defaultInitBranch); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_checkout", "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
|
||||
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
|
||||
}
|
||||
|
||||
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
|
||||
@@ -536,7 +538,7 @@ func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string
|
||||
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "internal", "cannot locate lark-cli executable: %v", 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 != "" {
|
||||
@@ -544,9 +546,9 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
|
||||
}
|
||||
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
|
||||
if err != nil {
|
||||
return "", output.ErrWithHint(output.ExitAPI, "credential_init",
|
||||
fmt.Sprintf("apps +git-credential-init failed: %s", gitErr(stderr, err)),
|
||||
"ensure apps +git-credential-init is available and you are logged in")
|
||||
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 parseRepoURLFromEnvelope(stdout)
|
||||
}
|
||||
@@ -560,7 +562,7 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
|
||||
func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) {
|
||||
status, stderr, runErr := initRunner.Run(ctx, dir, "git", "status", "--porcelain")
|
||||
if runErr != nil {
|
||||
return false, false, output.Errorf(output.ExitAPI, "git_status", "git status failed: %s", gitErr(stderr, runErr))
|
||||
return false, false, appsExternalToolError(runErr, "git status failed: %s", gitErr(stderr, runErr))
|
||||
}
|
||||
if strings.TrimSpace(status) == "" {
|
||||
return false, false, nil
|
||||
@@ -595,7 +597,7 @@ func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (commit
|
||||
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", "push", "origin", defaultInitBranch); e != nil {
|
||||
return true, false, withAppsHint(
|
||||
output.Errorf(output.ExitAPI, "git_push", "git push failed: %s", gitErr(se, e)),
|
||||
appsExternalToolError(e, "git push failed: %s", gitErr(se, e)),
|
||||
"the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded")
|
||||
}
|
||||
return true, true, nil
|
||||
@@ -609,10 +611,10 @@ func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (commit
|
||||
func stageAndCommit(ctx context.Context, dir, message string, pathspecs ...string) error {
|
||||
addArgs := append([]string{"add", "-A", "--"}, pathspecs...)
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", addArgs...); e != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_add", "git add failed: %s", gitErr(se, e))
|
||||
return appsExternalToolError(e, "git add failed: %s", gitErr(se, e))
|
||||
}
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_commit", "git commit failed: %s", gitErr(se, e))
|
||||
return appsExternalToolError(e, "git commit failed: %s", gitErr(se, e))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -1466,3 +1467,28 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
|
||||
t.Errorf("Description should mention app code: %q", AppsInit.Description)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
|
||||
// classification of an external-tool failure: a failing git subprocess
|
||||
// surfaces as internal/external_tool with the cause preserved.
|
||||
func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
|
||||
cause := errors.New("exit status 128")
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"git ls-files": {stderr: "fatal: not a git repository", err: cause},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from failing git subprocess")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeExternalTool {
|
||||
t.Fatalf("classification = %s/%s, want internal/external_tool", p.Category, p.Subtype)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -33,7 +32,7 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -32,10 +31,10 @@ var AppsReleaseGet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("release-id")) == "" {
|
||||
return output.ErrValidation("--release-id is required")
|
||||
return appsValidationParamError("--release-id", "--release-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user