Compare commits

..

20 Commits

Author SHA1 Message Date
lvxinsheng
a4c313c8f1 test: assert app-type html filter returns exactly html apps 2026-06-15 11:08:12 +08:00
lvxinsheng
a2c820643d test: add live e2e for apps +list 2026-06-12 20:08:50 +08:00
luozhixiong01
8e60f01474 feat(im): unify sort flags into --sort field and --order direction (#1302)
The 4 im query commands had three inconsistent sort conventions and leaked upstream API jargon (ByCreateTimeAsc, member_count_desc) directly to users. This PR unifies them on a single rule — --sort selects a field, --order selects a direction, both from fixed enums — so an agent only ever picks from an enum, never constructs a string. Old flags (--sort-type, --sort-by, and --sort on messages/threads) are kept as hidden silent aliases (no deprecation warning), so existing scripts keep working byte-for-byte.
2026-06-12 15:27:54 +08:00
JackZhao10086
465c789f7c feat: add --json flag support to auth subcommands (#1431)
* feat: add --json flag support to auth subcommands

* feat(auth/logout): add json output support for logout command

* feat(auth/list): add json output support for auth list command
2026-06-12 15:04:14 +08:00
Yuxuan Zhao
2a7e9c7d0d test(drive): retry duplicate-remote push in live E2E (#1403) 2026-06-12 13:48:19 +08:00
liangshuo-1
76ba6fad4f chore: add CODEOWNERS for internal/ and new skills domains (#1420) 2026-06-12 11:19:25 +08:00
liangshuo-1
510545f1e5 refactor(vc): consolidate note handling back into the vc domain (#1417) 2026-06-12 00:44:35 +08:00
max
c11cf3b716 feat: split note domain (#1345)
Add note shortcuts for note detail and unified transcript retrieval, route vc note detail parsing through the note domain, and update note/vc/minutes skill guidance for normal versus unified transcript handling.

Includes dry-run E2E coverage for the new note shortcuts and documents the remaining live E2E fixture gap.
2026-06-11 22:38:29 +08:00
liangshuo-1
ee2c93efeb chore: release v1.0.52 (#1412) 2026-06-11 22:05:51 +08:00
wangweiming-01
33e459a4de docs: optimize lark-drive skill routing (#1284)
* docs: optimize lark-drive skill routing

Change-Id: I79cebaa3e52b9291c89bdeffb50426e8f0f3bb2b

* docs: refine lark-drive skill guidance

Change-Id: I628291d6d2b60b0baa7202dddbb9a34138a27a3d
2026-06-11 20:19:07 +08:00
dc-bytedance
5aeae2db65 fix: harden riscv64 -race guard and restore Makefile newline
The cherry-picked riscv64 commit derived RACE_FLAG from `go env GOARCH`
via a grep pipeline, which ignores a GOARCH passed on the make command
line (e.g. `make GOARCH=riscv64 unit-test`) since command-line make
variables are not visible to $(shell ...). Switch to a make-native
filter that honors both, and restore the trailing newline the same
commit dropped.
2026-06-11 19:18:33 +08:00
Rocky Zhang
9b39d10203 feat: support riscv64 prebuilt binaries in release and install pipeline 2026-06-11 19:18:33 +08:00
Rocky Zhang
8572a58fda fix: support riscv64 by making -race flag arch-conditional 2026-06-11 19:18:33 +08:00
evandance
9bc66cc445 feat(apps): emit typed error envelopes across the apps domain (#1288) 2026-06-11 19:04:34 +08:00
shifengjuan-dev
e53f9d999e feat(im): add --chat-modes filter to chat search (#1317)
Add a server-side --chat-modes filter to the im +chat-search shortcut so
users can restrict results to regular groups and/or topic groups.

Change-Id: Ia59c2c05fb2e8e45bd741c8531ca0e3ca69de2f3
2026-06-11 16:54:27 +08:00
shifengjuan-dev
ae35b35693 docs(im): document chat.user_setting batch_query/batch_update (#1339)
Add the chat.user_setting resource 

Change-Id: Ifdd163bfa1cdbfcb56cbf12a3f52e40b61d85e2d
2026-06-11 16:52:05 +08:00
fangshuyu-768
c2e617fc96 docs(skills): expand cite user guidance and fix typos (#1394) 2026-06-11 16:40:39 +08:00
liuxinyanglxy
3f77eded9d feat: per-resource subscription identity + Match hook (#1185)
Framework support for resource-scoped event subscriptions, so one
EventKey can fan out into independent per-resource subscription scopes:

- KeyDefinition gains SubscriptionKey / NormalizeParams / Match hooks
- ComputeSubscriptionID derives a dedup identity from (EventKey, sub-key
  params); plumbed through bus Hub, consume loop, and the
  Hello / PreShutdownCheck / ConsumerInfo protocol messages
- add a synchronous Match filter stage before Process
- change PreConsume cleanup to func() error and surface cleanup
  (unsubscribe) failures as WARN with an idempotency note
- adapt minutes/vc/whiteboard PreConsume to the new cleanup signature
- render SubscriptionID / SubscriptionKey in event status & schema output

No domain wires these hooks yet; covered by unit tests using bus/protocol
doubles. (Mail, the original exerciser, is intentionally not included.)

Change-Id: Ifc743f1aa0bc4dff0c8a1e35da24883694fe7699
2026-06-11 16:22:04 +08:00
shifengjuan-dev
e64610f6d2 docs(im): document chat.managers and chat.moderation API resources (#1294)
Add SKILL.md entries for the group manager and group moderation
(speaking-permission) API-meta resources:
- chat.managers.add_managers / delete_managers (指定/删除群管理员)
- chat.moderation.get / update (查询/更新群发言权限)
2026-06-11 15:12:21 +08:00
raistlin042
dfa26c38f6 feat: exclude .git directory from apps +html-publish package (#1396)
* feat: exclude .git from html-publish package walk

* docs: note .git auto-exclusion in html-publish reference

* test: update html-publish e2e for .git exclusion

* docs: simplify .git skip comment in html-publish walker
2026-06-11 14:58:58 +08:00
163 changed files with 3668 additions and 6763 deletions

30
.github/CODEOWNERS vendored Normal file
View 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
View File

@@ -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

View File

@@ -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

View File

@@ -17,6 +17,7 @@ builds:
goarch:
- amd64
- arm64
- riscv64
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"

View File

@@ -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
```

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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())
}
}

View File

@@ -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
View 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())
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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{

View File

@@ -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()

View File

@@ -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) })

View File

@@ -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),
})

View File

@@ -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.
)

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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 }

View File

@@ -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")
}
}

View File

@@ -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 {

View 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")
}
}

View 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])
}

View 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)
}
}

View File

@@ -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
}

View File

@@ -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
}()

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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())
}
}

View File

@@ -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 {

View File

@@ -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{})

View File

@@ -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"`

View File

@@ -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
})
}
}

View File

@@ -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)
}
}

View File

@@ -18,6 +18,7 @@ var migratedCommonHelperPaths = []string{
"cmd/event/",
"events/",
"internal/event/consume/",
"shortcuts/apps/",
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",

View File

@@ -19,6 +19,7 @@ var migratedEnvelopePaths = []string{
"cmd/event/",
"events/",
"internal/event/consume/",
"shortcuts/apps/",
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",

View File

@@ -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"

View File

@@ -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];

View File

@@ -30,6 +30,7 @@ const PLATFORM_MAP = {
const ARCH_MAP = {
x64: "amd64",
arm64: "arm64",
riscv64: "riscv64",
};
const platform = PLATFORM_MAP[process.platform];

View File

@@ -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
},

View File

@@ -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 {

View File

@@ -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
},

View File

@@ -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
},

View File

@@ -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) {

View File

@@ -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_listcursor 分页)。--table 可重复传多张表;--since/--until 多格式时间。
// operator 透传 {id,name}json 还原对象、pretty 取 namebefore/after 是条件出现的 JSON
// INSERT 无 before、DELETE 无 afterjson 还原成对象。
//
// 多表查询时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 把服务端原始审计事件投影为白名单 auditLogItemoperator 解析、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, ", "))
}

View File

@@ -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_setbody {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_setbody {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}
}

View File

@@ -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)
}

View File

@@ -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)
// schemaorders/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"},
}}},
})
// statusorders/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()
// skippedcarts(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)
}
}

View File

@@ -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_listcursor 分页)。过滤:--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 变更记录投影为白名单 changelogItemoperator 解析成对象)。
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)
}

View File

@@ -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)
}
}

View File

@@ -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 行,故取 mintotal 查询失败时按导出内容数行兜底。
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 调 GetAppTableRecordListpage_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 → 取其扩展名定 formatcsv/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
}

View File

@@ -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 验证越界 --limit0/-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-runformat 跟随 --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")
}
}

View 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_importmultipart 表单file_name + 可选 table + 文件本体(与
// +file-upload / UploadFileForOpenAPI 一致)。文件的格式解析与转换在服务端 integration 层完成
// (按 file_name 扩展名推断 csv/jsonCLI 不再本地解析。表名缺省取文件名(去扩展名)。上限 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)
// multipartfile_name 走表单字段、文件本体走 form-filesenv / 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))
}

View File

@@ -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-runmultipart 上传——file_name + file 走 bodyenv + table 走 querytable 缺省取文件名)。
// 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)
}
}

View File

@@ -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_migratebody {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_migratebody {dry_run:false} → task_id轮询 env_migrate_status
// 至 success后端 status:appliedCLI 对外统一呈现 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 把服务端原始变更项投影为白名单 migrationChangetype/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
}

View File

@@ -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_idstatus 立刻 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)
}
}

View File

@@ -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.APIErrorCategoryAPI → exit 1避免 agent 误判 ok:true 假成功。诊断信息
// (第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地)写进 message+hint 文案errs.* 信封扁平、无
// detail 容器):失败在用户显式 BEGIN…COMMIT 事务内 → 整批回滚、前序未落库;否则前序语句已逐条
// commit、未回滚。rolled_back 语义由 inferRolledBack 按 BEGIN/COMMIT 计数推断
// 后按 partial failure 上报exit 非 0stdout 输出 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_errorexit 非 0别让 agent 误判 ok:true 假成功。
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout人看再返回 errorenvelope→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` 形态:
// - 无语句 → [](空数组)
// - 单条语句 → singleStatementJSONSELECT 是行数组、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.APIErrorCategoryAPI → exit 1
// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据
//
// 多语句失败的诊断信息——第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地——都写进
// message + hint 的人类可读文案errs.* 信封是扁平字段、不带结构化 detail 容器)。文案对齐
// miaoda-clisrc/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_typeBEGIN/START TRANSACTION +1COMMIT/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 ""

View File

@@ -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 单 SELECTdata 直接是行数组(不再是 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 单 DMLdata = {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 单 DDLdata = {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=falseDBA 模式)且 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 DDLpretty 输出 "(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 errorstatement_index=0、completed 空、message 标注 (at statement 1 of 1)。
// 同样走 partial failurestatement_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 判定回滚。
// 回滚语义现写在 hintmiaoda 原句 "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 实测 wireBEGIN; 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)
}
// 事务整批回滚 / 前序未落库 的语义写在 hintmiaoda 原句)。
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)
}
}

View File

@@ -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)
}

View File

@@ -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_recoverybody {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_recoverybody {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"]))
}
// describeRecoveryChangeschema 动作 或 数据行变化二选一(无 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, ", ")
}

View File

@@ -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
},

View File

@@ -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")
}
}

View File

@@ -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)
}
})
}
}

View 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
}

View 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)
}
}

View File

@@ -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 pathhigh-risk-write框架自动注入 --yes 确认)。
//
// POST /apps/{app_id}/storage/file_batch_removebody {paths:[...]}。网关把该路由注册为 POST
// DELETE-with-body 不被网关支持,实测 DELETE→404 / POST→200。后端 results[] 与请求 paths
// 顺序一一对应:成功项带 file失败项带 error_codeCLI 据下标回填 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))
}

View File

@@ -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_requiredexit 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_removebody.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:trueresults 按下标 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)
}
}
}

View File

@@ -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_urlpresigned直连对象存储
// 再客户端 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 是上游瞬时故障,标 retryable4xx如签名过期需重新签名而非盲重试不标。
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
},
}

View File

@@ -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)
}
}

View File

@@ -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/valuefile_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/valueuploaded_by 只展示 nameid 仅 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)
}

View File

@@ -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 filepath 作为 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/valuesize 含 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)
}
}

View File

@@ -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 逐项投影为白名单 fileInfocreated_*→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)
}

View File

@@ -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 带 TZZ 原样、显式偏移换算到 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-dur5s 容差)
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)
}
}
// 过滤器 + 分页全部进 querysize-gt/lt 走 intuploaded_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_bycreated_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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}
// 配额未对接(=0storage_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)
}
}

View File

@@ -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_signbody {path, expires_in}。
// pretty 模式只打 signed_url便于直接管道 / curljson 返 {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
}

View File

@@ -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_signbody 携带 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)
}
}

View File

@@ -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 取本地 basenamepath 由平台生成 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 把它存成对象 metadatacallback 阶段后端
// 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 是上游瞬时故障,标 retryable4xx如签名过期需重新签名而非盲重试不标。
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)
}
}

View File

@@ -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_uploadbody.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"}, // 空格 → %20encodeURIComponent
{`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)
}
}

View File

@@ -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{}{}

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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
},

View File

@@ -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